diff --git a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts index f9b391fc..6459d1ce 100644 --- a/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts +++ b/B07_DesignDetail/B07_DesignDetail_Api_Fetch.ts @@ -260,8 +260,9 @@ export interface StandardSheetsResponse { }[]; } +// 창구는 B08 구조물도 — 2026-09-13 이관(PLAN 3장). 탭 배선 뒤 B07 표준도와 함께 지움. export function fetchStandardSheets(projectId: string): Promise { - return requestJson(`/projects/${projectId}/standard-sheets`); + return requestJson(`/projects/${projectId}/quantity/structure-sheets`); } /** 장 하나의 제원 저장 — 빈 값(null)은 **그 칸을 지우라**는 뜻이다. */ @@ -282,7 +283,7 @@ export function putStandardSheetSpec( blinding_concrete: string | null; }, ): Promise<{ status: string; revision: number; changed: number; notes: string[] }> { - return requestJson(`/projects/${projectId}/standard-sheets/spec`, { + return requestJson(`/projects/${projectId}/quantity/structure-sheets/spec`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_StandardFigure.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_StandardFigure.py index 77b5ec01..53150fea 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_StandardFigure.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_StandardFigure.py @@ -37,9 +37,14 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( _text_entity, polyline_entity, ) + +# 기울기 판정은 구조물도 엔진이 가진 한 벌 — 2026-09-13 B08 로 이관(PLAN 3장). 받아 쓰기만 함. +from B08_Quantity.B08_Quantity_Engine_StructureSheet import ( # noqa: F401 + SLOPE_TYPE_IDS, + slope_of, +) from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( STONE_MASONRY, - face_slope_ratio, weep_hole_spec, ) from common_util.common_util_excavation import ( @@ -61,11 +66,6 @@ SCALE_MM_PER_M = 40.0 #: ⇒ 「직경에서 두께를 내는 식」은 **도메인 판단이라 사용자 몫** — 계획서 4-12 답 대기 줄. FIGURE_TYPE_IDS: frozenset[str] = frozenset({"masonry_wet", "masonry_dry"}) -#: **전면 기울기 판정** 대상 — 그림 대상과 **다르다**. 큰돌쌓기는 그림은 못 그려도 -#: 기울기는 판정된다(품셈 13-6 「1:0.3 이상」). 한 목록으로 묶어 두었더니 큰돌쌓기를 -#: 그림에서 뺄 때 **장 제목의 「1:0.3」까지 사라졌다**(2026-09-09 화면 실측에서 잡음). -SLOPE_TYPE_IDS: frozenset[str] = frozenset({"masonry_wet", "masonry_dry", "boulder_masonry"}) - #: 그림이 못 서는 장에 **까닭을 적는다** — 빈 자리를 그냥 두면 사용자가 「고장」으로 읽는다 #: (2026-09-09 사용자 지시). 「무엇을 받아야 서는지」까지 적는다. _NO_FIGURE_REASONS: dict[str, str] = { @@ -98,31 +98,6 @@ _LABEL_FONT = 4.0 _DIM_FONT = 3.4 -def slope_of(sheet: dict[str, Any]) -> tuple[float, str]: - """장 하나의 전면 기울기와 근거 문구 — **판정은 B08 한 벌**을 그대로 쓴다. - - 메/찰은 종류에서 온다. 큰돌쌓기는 `bond` 칸(메쌓기/찰쌓기)이 그것이고, 안 고르면 - 찰쌓기로 본다 — 품셈 13-6 은 둘 다 「1:0.3 **이상**」이라 그림 기울기가 갈리지 않는다. - """ - options = sheet.get("options") or {} - type_id = str(sheet.get("type_id") or "") - if type_id == "masonry_dry": - wet = False - elif type_id == "boulder_masonry": - wet = options.get("bond") != "메쌓기" - else: - wet = True - return face_slope_ratio( - options, - wet=wet, - height_m=float(sheet.get("height_m") or 0.0), - # ⚠ 표를 만든 그 판정을 그대로 넘긴다 — 여기서 다시 가르면 근거를 못 받아 - # 종전값으로 떨어지고 **표와 갈린다**(2026-09-09 실측). - face=sheet.get("face"), - face_reason=str(sheet.get("face_reason") or ""), - ) - - def wall_thickness(height_m: float, back_cm: float) -> tuple[float, float]: """(상부, 하부) 두께 — **수량이 쓰는 그 식**(`stone_masonry`)과 같은 상수를 읽는다.""" top_t = back_cm / 100.0 + STONE_MASONRY["thickness_top_add_m"] diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_StandardSheet.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_StandardSheet.py index 66d2d998..14944c61 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_StandardSheet.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_StandardSheet.py @@ -10,7 +10,7 @@ 계획평면도가 쓰는 장 나눔 본을 따라야 하고 목록·라우터 네 곳이 함께 움직여야 한다(㉢). 지금은 **값이 눈에 보이는 것**이 먼저라 한 장에 쌓고, 장 나눔은 그 다음이다. -⚠ 값을 여기서 셈하지 않는다 — `_Engine_Standard_Sheet.build_standard_sheets` 가 낸 것을 +⚠ 값을 여기서 셈하지 않는다 — `B08_Quantity_Engine_StructureSheet.build_standard_sheets` 가 낸 것을 글자로 옮길 뿐이다. 셈이 두 벌이 되면 도면과 수량서가 갈린다(CLAUDE.md 5장). """ @@ -232,18 +232,14 @@ def standard_payload( ⚠ 늦게 부른다(함수 안 import) — B08 은 B05 를 부르고 B05 는 다시 B07 을 부를 수 있어 모듈 맨 위에서 부르면 맞물린다. + ⚠ 장 목록은 **B08 구조물도 창구와 한 벌**(2026-09-13 이관) — 따로 전개하면 기초잡석 두께가 갈림. """ - from B07_DesignDetail.B07_DesignDetail_Engine_Standard_Sheet import build_standard_sheets - from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table - from B08_Quantity.B08_Quantity_Router_Material import _collect_structures + from B08_Quantity.B08_Quantity_Router_StructureSheet import project_structure_sheets try: - structures, names, _skipped = _collect_structures(str(project_root)) # ⚠ 단면유형을 넘겨야 성절토가 갈리고 표준경사 판정이 돈다. 안 넘기면 전 구조물이 # 「가를 근거 없음」으로 떨어져 종전값 1:0.3 으로 선다(2026-09-09 실측). - return build_standard_sheets( - build_unit_table(structures, names, section_modes), section_modes - ) + return project_structure_sheets(str(project_root), section_modes) except Exception: logger.exception("B07 표준도 장 목록 실패 — 빈 목록으로 둔다: %s", project_root) return {"sheets": [], "structure_count": 0} diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index a31b9b49..5a4fea79 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -31,7 +31,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( use_company_templates, use_title_fields, ) -from B07_DesignDetail.B07_DesignDetail_Router_Standard import section_modes_of +from B08_Quantity.B08_Quantity_Router_StructureSheet import section_modes_of from B07_DesignDetail.B07_DesignDetail_Router_Support import ( CROSS_STANDARD_ID, LANDUSE_ID, diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index 41f27a58..0f31bc54 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -50,10 +50,11 @@ import { } from "./B07_DesignDetail_Api_Fetch"; import { appendStructureEntities } from "./B07_DesignDetail_UI_Cad_Structures"; import { createFrameTemplateEditor } from "./B07_DesignDetail_UI_FrameEdit"; +// 제원 칸은 B08 구조물도로 이관(2026-09-13, PLAN 3장) — 탭 배선 뒤 B07 표준도와 함께 뺌. import { buildStandardSpecPanel, type StandardSpecResult, -} from "./B07_DesignDetail_UI_StandardSpec"; +} from "../B08_Quantity/B08_Quantity_UI_StructureSheet_Spec"; /** CAD 앱 수량 패널로 넘기는 설계 컨텍스트 (openwebcad DesignMeta와 동일 형식). */ interface DesignMeta { diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Style.css b/B07_DesignDetail/B07_DesignDetail_UI_Style.css index b22aeca4..994febee 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Style.css +++ b/B07_DesignDetail/B07_DesignDetail_UI_Style.css @@ -337,73 +337,4 @@ font-size: var(--text-caption); } -/* 표준도 제원 입력 — 좌측 목록 아래 정보 칸에 선다. 장 하나가 곧 제원 조합 하나다. */ -.b07-spec { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - padding: var(--spacing-12); - border-radius: var(--radius-lg); -} - -.b07-spec__title { - margin: 0; - font-size: var(--text-body-sm); - font-weight: 600; -} - -.b07-spec__scope { - margin: 0; - color: var(--color-text-muted); - font-size: var(--text-caption); -} - -.b07-spec__field { - display: flex; - flex-direction: column; - gap: 2px; -} - -.b07-spec__label { - color: var(--color-text-muted); - font-size: var(--text-caption); -} - -.b07-spec__input { - width: 100%; - min-width: 0; - padding: var(--spacing-4) var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-buttons); - background: var(--color-surface); - color: var(--color-text); - font: inherit; -} - -/* 「비우면 자동」처럼 **칸의 뜻**을 적는 자리 — 값이 아니라 규칙을 말한다. */ -.b07-spec__hint { - color: var(--color-text-muted); - font-size: var(--text-caption); -} - -.b07-spec__save { - padding: var(--spacing-8); - border: 1px solid transparent; - border-radius: var(--radius-buttons); - background: var(--color-primary, #7c3aed); - color: #fff; - cursor: pointer; -} - -.b07-spec__save:disabled { - opacity: 0.6; - cursor: default; -} - -/* 저장 뒤 안내 — 막지 않고 알리는 자리(품셈 범위 밖 기울기 등). */ -.b07-spec__notes { - margin: 0; - padding-left: var(--spacing-16); - color: var(--color-text-muted); - font-size: var(--text-caption); -} +/* 표준도 제원 입력 모양은 B08 로 이관(2026-09-13) — `B08_Quantity_UI_StructureSheet_Spec.ts` 가 스스로 넣음. */ diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Standard_Sheet.py b/B08_Quantity/B08_Quantity_Engine_StructureSheet.py similarity index 81% rename from B07_DesignDetail/B07_DesignDetail_Engine_Standard_Sheet.py rename to B08_Quantity/B08_Quantity_Engine_StructureSheet.py index 7461c79a..610e7cc1 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Standard_Sheet.py +++ b/B08_Quantity/B08_Quantity_Engine_StructureSheet.py @@ -20,6 +20,10 @@ ⚠ **단위당 값**은 성분 수량을 `billing_quantity` 로 나눈 것이다. 실무 시트 머리의 `m당` · `개소당` · `㎡당` 이 그 단위이고, **구조물마다 다르다**(통일하지 않음 — 4-1). + +⚠ **2026-09-13 B07 에서 이관**(PLAN 3장 · 사용자 확정 ③ 「구조물도는 B08 로」). + 옛 자리 `B07_DesignDetail_Engine_Standard_Sheet.py`. + B07 그림 파일을 부르지 않게 기울기 판정 대상도 함께 옮김. """ from __future__ import annotations @@ -27,6 +31,8 @@ from __future__ import annotations import json from typing import Any +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import BACK_LENGTH_KEYS, face_slope_ratio + #: 장을 가르지 **않는** 제원 칸 — 개소마다 다를 뿐 그림·단위수량을 안 바꾼다. #: 여기 없는 칸은 전부 장 나눔에 들어간다(모르는 칸을 빠뜨려 두 장이 한 장으로 합쳐지는 것보다, #: 장이 하나 더 서는 쪽이 안전하다 — 합쳐지면 값이 조용히 틀린다). @@ -86,13 +92,40 @@ def sheet_key(structure: dict[str, Any]) -> str: return json.dumps(payload, ensure_ascii=False, sort_keys=True) -def _slope_or_none(structure: dict[str, Any]) -> float | None: - """돌쌓기 계열이면 판정된 전면 기울기, 아니면 `None`. 판정은 그림 모듈이 가진 한 벌.""" - from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardFigure import ( - SLOPE_TYPE_IDS, - slope_of, +#: **전면 기울기 판정** 대상 — 그림 대상(`FIGURE_TYPE_IDS`)과 **다르다**. +#: 큰돌쌓기는 그림은 못 그려도 기울기는 판정된다(품셈 13-6 「1:0.3 이상」). +#: 한 목록으로 묶어 두었더니 큰돌쌓기를 그림에서 뺄 때 **장 제목의 「1:0.3」까지 +#: 사라졌다**(2026-09-09 화면 실측에서 잡음). +SLOPE_TYPE_IDS: frozenset[str] = frozenset({"masonry_wet", "masonry_dry", "boulder_masonry"}) + + +def slope_of(sheet: dict[str, Any]) -> tuple[float, str]: + """장 하나의 전면 기울기와 근거 문구 — **판정은 B08 한 벌**을 그대로 쓴다. + + 메/찰은 종류에서 온다. 큰돌쌓기는 `bond` 칸(메쌓기/찰쌓기)이 그것이고, 안 고르면 + 찰쌓기로 본다 — 품셈 13-6 은 둘 다 「1:0.3 **이상**」이라 그림 기울기가 갈리지 않는다. + """ + options = sheet.get("options") or {} + type_id = str(sheet.get("type_id") or "") + if type_id == "masonry_dry": + wet = False + elif type_id == "boulder_masonry": + wet = options.get("bond") != "메쌓기" + else: + wet = True + return face_slope_ratio( + options, + wet=wet, + height_m=float(sheet.get("height_m") or 0.0), + # ⚠ 표를 만든 그 판정을 그대로 넘긴다 — 여기서 다시 가르면 근거를 못 받아 + # 종전값으로 떨어지고 **표와 갈린다**(2026-09-09 실측). + face=sheet.get("face"), + face_reason=str(sheet.get("face_reason") or ""), ) + +def _slope_or_none(structure: dict[str, Any]) -> float | None: + """돌쌓기 계열이면 판정된 전면 기울기, 아니면 `None`.""" # ⚠ **그림 대상 목록을 쓰지 않는다** — 큰돌쌓기는 그림은 못 그려도 기울기는 판정된다. if str(structure.get("type_id") or "") not in SLOPE_TYPE_IDS: return None @@ -111,7 +144,8 @@ def sheet_title(structure: dict[str, Any]) -> str: slope = _slope_or_none(structure) if slope is not None: parts.append(f"1:{slope:g}") - back = options.get("back_len_cm") or options.get("stone_back_length_cm") + # 옛 저장분 키까지 읽는 차례는 전개의 한 벌(`BACK_LENGTH_KEYS`) — 키 이름을 다시 적지 않음. + back = next((options[key] for key in BACK_LENGTH_KEYS if options.get(key)), None) if back: parts.append(f"뒷길이 {int(back)}㎝") kind = options.get("stone_kind") @@ -137,6 +171,8 @@ def _rows_of(structure: dict[str, Any]) -> list[dict[str, Any]]: { "no": index, "name": component.get("name") or "", + # 실무 시트 머리의 「규격」 칸(210 · Ø50 등) — 전개가 이미 싣고 옴(2026-09-13). + "spec": component.get("spec") or "", # 실무 시트의 「산출근거」 칸 — B08 이 이미 사람이 읽는 문구로 낸다. "basis": component.get("basis") or "", "unit_amount": _unit_amount(amount, quantity), diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Standard_Edit.py b/B08_Quantity/B08_Quantity_Engine_StructureSheet_Edit.py similarity index 99% rename from B07_DesignDetail/B07_DesignDetail_Engine_Standard_Edit.py rename to B08_Quantity/B08_Quantity_Engine_StructureSheet_Edit.py index 8d89a8c7..f8646cea 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Standard_Edit.py +++ b/B08_Quantity/B08_Quantity_Engine_StructureSheet_Edit.py @@ -16,6 +16,8 @@ ⚠ **고치면 장이 갈릴 수 있다** — 그 조합 전부에 같은 값을 넣으므로 장은 통째로 옮겨 가고 쪼개지지 않는다. 한 개소만 다르게 하려면 그 구조물을 따로 고쳐야 하고, 그때 새 조합이 되어 장이 하나 는다(PLAN 4-5b). + +⚠ **2026-09-13 B07 에서 이관**(PLAN 3장). 옛 자리 `B07_DesignDetail_Engine_Standard_Edit.py`. """ from __future__ import annotations diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Standard.py b/B08_Quantity/B08_Quantity_Router_StructureSheet.py similarity index 58% rename from B07_DesignDetail/B07_DesignDetail_Router_Standard.py rename to B08_Quantity/B08_Quantity_Router_StructureSheet.py index 8eca6dcc..1cf818ed 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Standard.py +++ b/B08_Quantity/B08_Quantity_Router_StructureSheet.py @@ -1,4 +1,7 @@ -"""B07 표준도(구조물도) 라우터 — 장 목록 조회와 **제원 입력**. +"""B08 구조물도(표준도) 라우터 — 장 목록 조회와 **제원 입력**. + +⚠ **2026-09-13 B07 에서 이관**(PLAN 3장 · 사용자 확정 ③ 「구조물도는 B08 로」). + 옛 자리 `B07_DesignDetail_Router_Standard.py` · 옛 주소 `/standard-sheets`. 표준도는 도면이자 **입력 화면**이다(PLAN 4-5b). `phase: "detail"` 칸(돌 종류·조달·뒷길이· 전면 기울기)을 그리는 화면이 없어(2026-09-09 실측) 그 자리를 여기가 맡는다. @@ -11,6 +14,7 @@ import asyncio import logging from pathlib import Path +from typing import Any from uuid import UUID from fastapi import APIRouter @@ -19,24 +23,20 @@ from pydantic import BaseModel, ConfigDict, Field from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from common_util.common_util_storage import resolve_stored_project_path -from config.config_db import get_db_pool +from config.config_db import run_with_connection logger = logging.getLogger(__name__) -router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"]) +router = APIRouter(prefix="/api/projects", tags=["B08 Quantity"]) -async def section_modes_of_project(project_id: UUID) -> dict[float, str]: - """프로젝트에서 노선을 찾아 단면유형 표를 낸다 — 노선을 모르면 빈 표.""" - from B06_Section.B06_Section_Repository import get_workflow_route_context - from config.config_db import run_with_connection - +async def _project_root(project_id: UUID) -> str | None: + """프로젝트 저장 폴더(절대경로). 못 찾으면 `None`.""" try: - context = await run_with_connection(get_workflow_route_context, project_id) - route_id = int((context or {}).get("route_id") or 0) + stored_path = await run_with_connection(get_project_storage_relative_path, project_id) + return str(Path(resolve_stored_project_path(stored_path)).resolve()) except Exception: - logger.exception("B07 노선 조회 실패: project_id=%s", project_id) - return {} - return await section_modes_of(route_id) if route_id else {} + logger.exception("B08 구조물도 — 저장 폴더 조회 실패: project_id=%s", project_id) + return None async def section_modes_of(route_id: int) -> dict[float, str]: @@ -46,19 +46,70 @@ async def section_modes_of(route_id: int) -> dict[float, str]: 1:0.3 으로 선다**(2026-09-09 실측). 값이 없는 것이 아니라 **안 넘긴 것**이었다. ⚠ 표를 만드는 셈은 `section_modes_from_designs` 한 벌을 쓴다 — 부르는 쪽마다 다시 짜면 B08 과 갈린다. + ⚠ 과도기 — 노선 id 로 부르는 쪽은 B07 표준도 도면뿐임. + 탭 배선 뒤 B07 에서 표준도를 빼면 함께 지움. """ from B06_Section.B06_Section_Repository import get_cross_section_designs from B08_Quantity.B08_Quantity_Engine_UnitQuantity import section_modes_from_designs - from config.config_db import run_with_connection try: designs = await run_with_connection(get_cross_section_designs, route_id) except Exception: - logger.exception("B07 단면유형 조회 실패: route_id=%s", route_id) + logger.exception("B08 구조물도 단면유형 조회 실패: route_id=%s", route_id) return {} return section_modes_from_designs(designs) +def project_structure_sheets( + project_root: str, + section_modes: dict[float, str] | None, + ground_types: dict[float, str] | None = None, +) -> dict[str, Any]: + """구조물도 장 목록 — **원단위 탭(`material-summary`)과 같은 입력**으로 전개해 접는다. + + ⚠ 기초잡석 두께를 안 넘기면 산출 조건에서 바꿔도 **구조물도만 옛 두께(0.2)** 로 선다 + (2026-09-13 이관 때 잡음 — 옛 B07 창구가 두께·지반 갈래를 안 넘겼음). + ⚠ 늦게 부른다(함수 안 import) — B08 은 B05 를 부르고 B05 는 다시 B07 을 부를 수 있어 + 모듈 맨 위에서 부르면 맞물린다. + """ + from B08_Quantity.B08_Quantity_Engine_StructureSheet import build_standard_sheets + from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table + from B08_Quantity.B08_Quantity_Router_Material import _collect_structures + from common_util.common_util_project_settings import quantity_settings + + structures, names, skipped = _collect_structures(project_root) + unit_table = build_unit_table( + structures, + names, + section_modes, + ground_types, + quantity_settings(project_root).get("rubble_base_thickness_m"), + ) + payload = build_standard_sheets(unit_table, section_modes) + # 왜 안 실렸는지 — 「구조물이 없다」와 「걸러졌다」를 화면이 가릴 수 있어야 한다. + payload["skipped_structures"] = skipped + return payload + + +async def _sheets_of(project_id: UUID, project_root: str) -> dict[str, Any]: + """조회·저장이 **같은 장 목록**을 보게 하는 한 문. + + 단면유형·지반 갈래는 원단위 탭 창구(`B08_Quantity_Router_Material`)를 그대로 씀. + """ + from B08_Quantity.B08_Quantity_Router_Material import _ground_types, _section_modes + + modes = await _section_modes(project_id) + ground = await _ground_types(project_id) + return await asyncio.to_thread(project_structure_sheets, project_root, modes, ground) + + +def _not_found() -> JSONResponse: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."}, + ) + + class StandardSheetSpecRequest(BaseModel): """표준도 장 하나의 제원. **빈 값(null)은 「정한 적 없음」**이라 그 칸을 지운다.""" @@ -78,8 +129,8 @@ class StandardSheetSpecRequest(BaseModel): blinding_concrete: str | None = None -@router.put("/{project_id}/standard-sheets/spec") -async def put_standard_sheet_spec( +@router.put("/{project_id}/quantity/structure-sheets/spec") +async def put_structure_sheet_spec( project_id: UUID, payload: StandardSheetSpecRequest ) -> JSONResponse: """장 하나의 제원을 고쳐 **그 조합의 구조물 전부**에 반영한다. @@ -89,28 +140,21 @@ async def put_standard_sheet_spec( """ from B05_Profile.B05_Profile_Structures_Repository import load_structures, save_structures from B05_Profile.B05_Profile_Structures_Schema import structure_type_map - from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardSheet import standard_payload - from B07_DesignDetail.B07_DesignDetail_Engine_Standard_Edit import ( + from B08_Quantity.B08_Quantity_Engine_StructureSheet_Edit import ( apply_spec, clean_spec, drop_unregistered, ) - try: - pool = get_db_pool() - async with pool.acquire() as connection: - stored_path = await get_project_storage_relative_path(connection, project_id) - project_root = Path(resolve_stored_project_path(stored_path)).resolve() - except Exception: - logger.exception("B07 표준도 제원 저장 실패(경로): project_id=%s", project_id) - return JSONResponse( - status_code=404, - content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."}, - ) + project_root = await _project_root(project_id) + if project_root is None: + return _not_found() - modes = await section_modes_of_project(project_id) - payload_sheets = await asyncio.to_thread(standard_payload, project_root, modes) - sheets = payload_sheets.get("sheets") or [] + try: + sheets = (await _sheets_of(project_id, project_root)).get("sheets") or [] + except Exception: + logger.exception("B08 구조물도 제원 저장 실패(장 목록): project_id=%s", project_id) + sheets = [] picked = next((s for s in sheets if s.get("key") == payload.sheet_key), None) if picked is None: return JSONResponse( @@ -129,13 +173,13 @@ async def put_standard_sheet_spec( spec, missing = drop_unregistered(type_id, spec, allowed) notes.extend(missing) try: - revision, stored = await asyncio.to_thread(load_structures, str(project_root)) + revision, stored = await asyncio.to_thread(load_structures, project_root) updated, changed = apply_spec(stored, member_ids, spec) new_revision = await asyncio.to_thread( - save_structures, str(project_root), updated, base_revision=payload.base_revision + save_structures, project_root, updated, base_revision=payload.base_revision ) except Exception as exc: - logger.exception("B07 표준도 제원 저장 실패: project_id=%s", project_id) + logger.exception("B08 구조물도 제원 저장 실패: project_id=%s", project_id) return JSONResponse( status_code=409, content={"status": "error", "message": f"제원을 저장하지 못했습니다 — {exc}"}, @@ -154,43 +198,26 @@ async def put_standard_sheet_spec( ) -@router.get("/{project_id}/standard-sheets") -async def get_standard_sheets(project_id: UUID) -> JSONResponse: - """표준도(구조물도) **장 목록 + 하단표**. +@router.get("/{project_id}/quantity/structure-sheets") +async def get_structure_sheets(project_id: UUID) -> JSONResponse: + """구조물도(표준도) **장 목록 + 원단위 수량표**. ⚠ 수량을 여기서 새로 셈하지 않는다 — B08 원단위 전개를 그대로 받아 **제원 조합으로 묶고 단위당으로 접기만** 한다(계산 자리는 한 곳, CLAUDE.md 5장). """ - from B07_DesignDetail.B07_DesignDetail_Engine_Standard_Sheet import build_standard_sheets - from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table - from B08_Quantity.B08_Quantity_Router_Material import _collect_structures + project_root = await _project_root(project_id) + if project_root is None: + return _not_found() try: - pool = get_db_pool() - async with pool.acquire() as connection: - stored_path = await get_project_storage_relative_path(connection, project_id) - project_root = str(Path(resolve_stored_project_path(stored_path)).resolve()) + payload = await _sheets_of(project_id, project_root) except Exception: - logger.exception("B07 표준도 조회 실패(경로): project_id=%s", project_id) - return JSONResponse( - status_code=404, - content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."}, - ) - - modes = await section_modes_of_project(project_id) - try: - structures, names, skipped = await asyncio.to_thread(_collect_structures, project_root) - unit_table = await asyncio.to_thread(build_unit_table, structures, names, modes) - except Exception: - logger.exception("B07 표준도 전개 실패: project_id=%s", project_id) + logger.exception("B08 구조물도 전개 실패: project_id=%s", project_id) return JSONResponse( status_code=500, content={"status": "error", "message": "구조물 원단위를 전개하지 못했습니다."}, ) - payload = build_standard_sheets(unit_table, modes) payload["status"] = "success" payload["project_id"] = str(project_id) - # 왜 안 실렸는지 — 「구조물이 없다」와 「걸러졌다」를 화면이 가릴 수 있어야 한다. - payload["skipped_structures"] = skipped return JSONResponse(content=payload) diff --git a/B08_Quantity/B08_Quantity_UI_StructureSheet.ts b/B08_Quantity/B08_Quantity_UI_StructureSheet.ts new file mode 100644 index 00000000..1e8d1e18 --- /dev/null +++ b/B08_Quantity/B08_Quantity_UI_StructureSheet.ts @@ -0,0 +1,314 @@ +/* ============================================================================= + * B08_Quantity_UI_StructureSheet.ts + * 구조물도 탭 — 제원 조합 하나 = 한 장. 장마다 하위 탭 · 원단위 수량표 · 제원 칸 (PLAN 3장). + * + * ⛔ 탭 등록은 이 파일이 하지 않음 — `B08_Quantity_UI_Page.ts` 탭 배선은 브레인 몫(PLAN 0장 충돌 막이). + * 부르는 법: `{ label: "구조물도", build: () => renderStructureSheets(projectId) }` + * ⚠ 값을 셈하지 않음 — 서버(`/quantity/structure-sheets`)가 낸 단위당 값을 표기 자리수로만 접음. + * ⚠ 상단 그림·하단 일위대가는 뒤 일감 — 지금은 가운데 원단위 수량표만. + * ⚠ 제원 저장은 칸 옆 [제원 저장] 한 번에 정본(`structures.json`)으로 감 — 옛 B07 폼 규약 그대로. + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { fetchStructures } from "../B05_Profile/B05_Profile_Api_Structures"; +import { stationLabel } from "./B08_Quantity_UI_EarthworkGrid"; +import { injectEarthworkGridStyles } from "./B08_Quantity_UI_EarthworkGrid_Style"; +import { + buildStandardSpecPanel, + type StandardSheetSpec, + type StandardSpecResult, +} from "./B08_Quantity_UI_StructureSheet_Spec"; + +export interface StructureSheetRow { + no: number; + name: string; + spec: string; + basis: string; + /** 단위당 값 — 단위 수량을 못 정한 장은 `null`(0 으로 때우지 않음). */ + unit_amount: number | null; + amount: number; + unit: string; + basis_kind: string; + source: string; +} + +export interface StructureSheet extends StandardSheetSpec { + height_m: number; + unit_label: string; + billing_unit: string; + billing_total: number; + rows: StructureSheetRow[]; + members: { + structure_id: string | null; + name: string; + start_m: number | null; + end_m: number | null; + length_m: number; + billing_quantity: number; + }[]; + notes: string[]; + unpriced_rows: string[]; +} + +export interface StructureSheetsResponse { + sheets: StructureSheet[]; + sheet_count: number; + structure_count: number; + skipped_structures: string[]; + pending_choices: { label: string; effect?: string }[]; +} + +const STYLE_ID = "b08-structure-sheet-style"; +const CSS = ` +.b08-sheet { display: flex; gap: 12px; align-items: flex-start; min-height: 0; flex: 1 1 auto; } +.b08-sheet__main { display: flex; flex-direction: column; gap: 8px; flex: 1 1 auto; min-width: 0; min-height: 0; } +.b08-sheet__aside { flex: 0 0 17rem; max-height: 100%; overflow: auto; } +.b08-sheet__head { display: flex; justify-content: space-between; gap: 8px; margin: 0; font-size: 13px; color: var(--color-text); } +.b08-sheet__tabs { flex-wrap: wrap; } +.b08-sheet .b08-grid__table--summary td:nth-child(5) { text-align: center; } +/* 산출 근거는 길다 — 접지 않으면 수량 칸이 화면 밖으로 밀림(2026-09-13 화면 실측). */ +.b08-sheet__rows td:nth-child(3) { white-space: normal; min-width: 16rem; } +@media (max-width: 900px) { + .b08-sheet { flex-direction: column; } + .b08-sheet__aside { flex-basis: auto; width: 100%; } +} +`; + +function injectStyles(): void { + injectEarthworkGridStyles(); + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = CSS; + document.head.append(style); +} + +async function fetchStructureSheets(projectId: string): Promise { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-sheets`, + { credentials: "include" }, + ); + if (!response.ok) throw new Error(`structure sheets failed: ${response.status}`); + return (await response.json()) as StructureSheetsResponse; +} + +async function putStructureSheetSpec( + projectId: string, + body: StandardSpecResult & { base_revision: number }, +): Promise<{ changed: number; notes: string[] }> { + const response = await fetch( + `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-sheets/spec`, + { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + const payload = (await response.json().catch(() => ({}))) as { + changed?: number; + notes?: string[]; + message?: string; + }; + // 실패 사유(판번호 충돌 등)는 폼 안내 칸에 그대로 뜬다 — 조용히 끝나면 저장된 줄 앎. + if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`); + return { changed: payload.changed ?? 0, notes: payload.notes ?? [] }; +} + +function el( + tag: K, + className: string, + text = "", +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + node.className = className; + node.textContent = text; + return node; +} + +function num(value: number | null | undefined, digits: number): string { + if (value === null || value === undefined || Number.isNaN(value)) return "-"; + return value.toLocaleString("ko-KR", { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }); +} + +function warn(title: string, items: string[]): HTMLElement | null { + if (!items.length) return null; + return el("p", "b08-grid__caption b08-grid__caption--warn", `${title}: ${items.join(" · ")}`); +} + +function table(head: string[], rows: string[][], extraClass = ""): HTMLElement { + const scroller = el("div", "b08-grid__scroll"); + const grid = el("table", `b08-grid__table b08-grid__table--summary ${extraClass}`.trim()); + const thead = document.createElement("thead"); + const headRow = document.createElement("tr"); + for (const label of head) headRow.append(el("th", "", label)); + thead.append(headRow); + const tbody = document.createElement("tbody"); + for (const cells of rows) { + const tr = document.createElement("tr"); + for (const text of cells) tr.append(el("td", "", text)); + tbody.append(tr); + } + grid.append(thead, tbody); + scroller.append(grid); + return scroller; +} + +/** 장 한 벌의 가운데 — 머리 · 원단위 수량표 · 막힌 사유 · 개소 목록. */ +function sheetBody(sheet: StructureSheet): HTMLElement { + const main = el("div", "b08-sheet__main"); + const head = el("p", "b08-sheet__head"); + const total = sheet.billing_total + ? ` · 합 ${num(sheet.billing_total, 2)}${sheet.billing_unit}` + : ""; + head.append( + el("span", "", `${sheet.title} — ${sheet.member_count}개소${total}`), + // 실무 시트 머리의 「m당」·「개소당」 — 종류마다 다름(통일하지 않음). + el("span", "b08-grid__caption", sheet.unit_label), + ); + main.append(head); + if (!sheet.rows.length) { + main.append( + el("p", "b08-quantity__message", "이 제원은 원단위 줄이 서지 않음 — 아래 사유 참고"), + ); + } else { + main.append( + table( + ["공종", "규격", "산출 근거", "수량", "단위", "비고"], + sheet.rows.map((row) => [ + row.name, + row.spec, + // 근거 문구의 `**강조**` 는 서버 문서용 표기 — 표에서는 떼고 보임. + row.basis.replace(/\*\*/g, ""), + num(row.unit_amount, 3), + row.unit, + row.basis_kind === "observed" ? "실무 관측" : "치수 전개", + ]), + "b08-sheet__rows", + ), + ); + } + for (const notice of [ + warn("단위당을 못 낸 줄", sheet.unpriced_rows), + warn( + "사유", + sheet.notes.map((note) => note.replace(/\*\*/g, "")), + ), + ]) { + if (notice) main.append(notice); + } + main.append( + el("p", "b08-grid__caption", "이 장에 묶인 개소"), + table( + ["구조물", "구간", "연장(m)", `수량(${sheet.billing_unit})`], + sheet.members.map((member) => { + const { start_m: start, end_m: end } = member; + const span = + typeof start === "number" && typeof end === "number" + ? start === end + ? stationLabel(start) + : `${stationLabel(start)} ~ ${stationLabel(end)}` + : ""; + return [member.name, span, num(member.length_m, 1), num(member.billing_quantity, 2)]; + }), + ), + ); + return main; +} + +/** 구조물도 탭 본문. 받아 오는 동안 안내를 띄우고, 제원을 저장하면 **정본에서 다시 받아** 그림. */ +export function renderStructureSheets(projectId: string | null): HTMLElement { + injectStyles(); + const wrap = el("div", "b08-grid"); + if (!projectId) { + wrap.append(el("p", "b08-quantity__message", "프로젝트를 먼저 고를 것")); + return wrap; + } + + const paint = (response: StructureSheetsResponse, memberId: string | null, notes: string[]) => { + const sheets = response.sheets ?? []; + const caption = el( + "p", + "b08-grid__caption", + `구조물도 ${sheets.length}장 · 구조물 ${response.structure_count}개 · 제원 조합 하나가 한 장 · 할증 전 값`, + ); + const nodes: HTMLElement[] = [caption]; + const skipped = warn("건너뛴 구조물", response.skipped_structures ?? []); + if (skipped) nodes.push(skipped); + for (const choice of response.pending_choices ?? []) { + const effect = choice.effect ? ` · ${choice.effect.replace(/\*\*/g, "")}` : ""; + nodes.push(el("p", "b08-quantity__notice", `⚠ 미확정: ${choice.label}${effect}`)); + } + if (!sheets.length) { + nodes.push( + el( + "p", + "b08-quantity__message", + response.structure_count + ? "구조물이 모두 다른 단계에서 셈되어 구조물도에 실리지 않음" + : "배치된 구조물이 없음 — 구조물을 먼저 배치할 것", + ), + ); + wrap.replaceChildren(...nodes); + return; + } + + // 저장 뒤에는 **같은 개소가 든 장**을 다시 연다 — 제원이 바뀌면 장 이름(key)도 바뀌기 때문. + const found = sheets.findIndex((sheet) => + sheet.members.some((member) => memberId && member.structure_id === memberId), + ); + const tabs = el("div", "b08-quantity__tabs b08-sheet__tabs"); + const pane = el("div", "b08-sheet"); + const buttons: HTMLButtonElement[] = []; + const show = (index: number, initialNotes: string[] = []): void => { + buttons.forEach((button, i) => button.classList.toggle("is-active", i === index)); + const sheet = sheets[index]; + const aside = el("div", "b08-sheet__aside"); + // 판정된 기울기는 **칸에 적지 않고 도움말로만** — 적어 두면 「안 정함」이 사라진다. + const judged = /1:([\d.]+)/.exec(sheet.title)?.[1] ?? null; + aside.append( + buildStandardSpecPanel( + sheet, + judged, + async (result) => { + const { revision } = await fetchStructures(projectId); + const saved = await putStructureSheetSpec(projectId, { + ...result, + base_revision: revision, + }); + const after = [`${saved.changed}개소에 반영했습니다.`, ...saved.notes]; + // 정본이 바뀌었으니 표를 **다시 받아** 그린다 — 화면이 두 번째 정본이 되면 안 됨. + await load(sheet.members[0]?.structure_id ?? null, after); + return after; + }, + initialNotes, + ), + ); + pane.replaceChildren(sheetBody(sheet), aside); + }; + sheets.forEach((sheet, index) => { + const button = el("button", "b08-quantity__tab", `${index + 1}. ${sheet.title}`); + button.type = "button"; + button.addEventListener("click", () => show(index)); + buttons.push(button); + tabs.append(button); + }); + wrap.replaceChildren(...nodes, tabs, pane); + show(found >= 0 ? found : 0, notes); + }; + + const load = async (memberId: string | null = null, notes: string[] = []): Promise => { + try { + paint(await fetchStructureSheets(projectId), memberId, notes); + } catch { + wrap.replaceChildren(el("p", "b08-quantity__message", "구조물도를 불러오지 못함")); + } + }; + + wrap.append(el("p", "b08-quantity__message", "구조물도를 불러오는 중…")); + void load(); + return wrap; +} diff --git a/B07_DesignDetail/B07_DesignDetail_UI_StandardSpec.ts b/B08_Quantity/B08_Quantity_UI_StructureSheet_Spec.ts similarity index 77% rename from B07_DesignDetail/B07_DesignDetail_UI_StandardSpec.ts rename to B08_Quantity/B08_Quantity_UI_StructureSheet_Spec.ts index 11dd93f7..8e238b1c 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_StandardSpec.ts +++ b/B08_Quantity/B08_Quantity_UI_StructureSheet_Spec.ts @@ -1,7 +1,10 @@ /* ============================================================================= - * B07_DesignDetail_UI_StandardSpec.ts + * B08_Quantity_UI_StructureSheet_Spec.ts * 표준도 **제원 입력 칸** — 장 하나가 곧 제원 조합 하나이므로 여기서 고치면 그 조합 전부. * + * ⚠ 2026-09-13 B07 에서 이관(PLAN 3장) — 옛 자리 `B07_DesignDetail_UI_StandardSpec.ts`. + * B07 표준도 목록이 과도기 동안 이 파일을 함께 씀 — 그래서 모양(CSS)을 **이 파일이 스스로 넣음**. + * * 왜 여기인가 (PLAN 4-5b) — `phase: "detail"` 칸(돌 종류·조달·뒷길이·전면 기울기)을 그리는 * 화면이 없었다(2026-09-09 실측: B06 구조물 폼은 b05 phase 열 칸만 그림). B06 은 * 「어디에·몇 m」(배치), 표준도는 「어떤 제원」이다. @@ -53,16 +56,50 @@ const STONE_TYPES = new Set(["masonry_wet", "masonry_dry", "boulder_masonry"]); /** 벽 두께 칸을 받는 종류 — **큰돌쌓기는 두께 식이 달라** 등록부에 칸이 없다(2026-09-09). */ const THICKNESS_TYPES = new Set(["masonry_wet", "masonry_dry"]); +const STYLE_ID = "b08-spec-style"; + +/* 옛 B07 페이지 CSS(`B07_DesignDetail_UI_Style.css`)에서 옮긴 것 — 두 페이지가 같은 모양을 보게. */ +const CSS = ` +.b08-spec { display: flex; flex-direction: column; gap: var(--spacing-8); padding: var(--spacing-12); border-radius: var(--radius-lg); } +.b08-spec__title { margin: 0; font-size: var(--text-body-sm); font-weight: 600; } +.b08-spec__scope { margin: 0; color: var(--color-text-muted); font-size: var(--text-caption); } +.b08-spec__field { display: flex; flex-direction: column; gap: 2px; } +.b08-spec__label { color: var(--color-text-muted); font-size: var(--text-caption); } +.b08-spec__input { + width: 100%; min-width: 0; padding: var(--spacing-4) var(--spacing-8); + border: 1px solid var(--color-border); border-radius: var(--radius-buttons); + background: var(--color-surface); color: var(--color-text); font: inherit; +} +/* 「비우면 자동」처럼 **칸의 뜻**을 적는 자리 — 값이 아니라 규칙을 말한다. */ +.b08-spec__hint { color: var(--color-text-muted); font-size: var(--text-caption); } +.b08-spec__save { + padding: var(--spacing-8); border: 1px solid transparent; border-radius: var(--radius-buttons); + background: var(--color-primary, #7c3aed); color: #fff; cursor: pointer; +} +.b08-spec__save:disabled { opacity: 0.6; cursor: default; } +/* 저장 뒤 안내 — 막지 않고 알리는 자리(품셈 범위 밖 기울기 등). */ +.b08-spec__notes { margin: 0; padding-left: var(--spacing-16); color: var(--color-text-muted); font-size: var(--text-caption); } +`; + +/** 모양을 한 번만 넣는다 — 폼을 다시 그려도 겹치지 않음. */ +function injectSpecStyles(): void { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = CSS; + document.head.append(style); +} + function field(label: string, control: HTMLElement, hint?: string): HTMLLabelElement { const wrap = document.createElement("label"); - wrap.className = "b07-spec__field"; + wrap.className = "b08-spec__field"; const name = document.createElement("span"); - name.className = "b07-spec__label"; + name.className = "b08-spec__label"; name.textContent = label; wrap.append(name, control); if (hint) { const help = document.createElement("small"); - help.className = "b07-spec__hint"; + help.className = "b08-spec__hint"; help.textContent = hint; wrap.append(help); } @@ -75,7 +112,7 @@ function select( autoLabel: string, ): HTMLSelectElement { const el = document.createElement("select"); - el.className = "b07-spec__input"; + el.className = "b08-spec__input"; // 첫 보기가 **빈 값** — 「정한 적 없음」이 고를 수 있는 상태여야 한다. const blank = document.createElement("option"); blank.value = ""; @@ -100,20 +137,21 @@ export function buildStandardSpecPanel( onSave: (result: StandardSpecResult) => Promise, initialNotes: string[] = [], ): HTMLDivElement { + injectSpecStyles(); const panel = document.createElement("div"); - panel.className = "b07-spec ui-sidebar-section"; + panel.className = "b08-spec ui-sidebar-section"; const title = document.createElement("h3"); - title.className = "b07-spec__title"; + title.className = "b08-spec__title"; title.textContent = `제원 — ${sheet.title}`; const scope = document.createElement("p"); - scope.className = "b07-spec__scope"; + scope.className = "b08-spec__scope"; scope.textContent = `이 장의 ${sheet.member_count}개소에 함께 걸립니다.`; panel.append(title, scope); if (!STONE_TYPES.has(sheet.type_id)) { const none = document.createElement("p"); - none.className = "b07-spec__scope"; + none.className = "b08-spec__scope"; none.textContent = "이 종류는 표준도에서 받는 제원 칸이 아직 없습니다."; panel.append(none); return panel; @@ -129,7 +167,7 @@ export function buildStandardSpecPanel( const blinding = select(BLINDINGS, options.blinding_concrete, "— 안 정함(넣음) —"); const slope = document.createElement("input"); - slope.className = "b07-spec__input"; + slope.className = "b08-spec__input"; slope.type = "text"; slope.inputMode = "decimal"; slope.placeholder = "비우면 자동"; @@ -138,7 +176,7 @@ export function buildStandardSpecPanel( // 벽 두께 — 비우면 실무 구조물도 식(상부 = 뒷길이 + 0.30 · 하부 = 상부 + 0.30×(H−1)). const thickness = (key: "thickness_top_m" | "thickness_bottom_m"): HTMLInputElement => { const input = document.createElement("input"); - input.className = "b07-spec__input"; + input.className = "b08-spec__input"; input.type = "text"; input.inputMode = "decimal"; input.placeholder = "비우면 식"; @@ -170,7 +208,7 @@ export function buildStandardSpecPanel( } const notes = document.createElement("ul"); - notes.className = "b07-spec__notes"; + notes.className = "b08-spec__notes"; // ⚠ 저장하면 표·그림을 다시 받으면서 **이 폼이 통째로 새로 그려진다** — 그때 안내가 // 지워지지 않게 밖에서 들고 있다가 다시 넣는다(2026-09-09 실화면에서 잡음). const showNotes = (messages: string[]): void => { @@ -187,7 +225,7 @@ export function buildStandardSpecPanel( const save = document.createElement("button"); save.type = "button"; - save.className = "b07-spec__save"; + save.className = "b08-spec__save"; save.textContent = "제원 저장"; save.addEventListener("click", () => { void (async () => { diff --git a/docs/raw/verification/2026-09-13_축C_명세.md b/docs/raw/verification/2026-09-13_축C_명세.md index 9764cfdd..f0cc73ea 100644 --- a/docs/raw/verification/2026-09-13_축C_명세.md +++ b/docs/raw/verification/2026-09-13_축C_명세.md @@ -301,6 +301,19 @@ STmate 코드는 `B00001`·`M00042` 꼴로 **층 글자 + 다섯 자리**이고 (`JDCODE_M.BSC` 자재 57,580건 등)는 여전히 암호화라 못 읽음. 남의 내부 번호를 정본으로 삼으면 나중에 빈 자리를 못 메움 +### ⚠⚠ 규격은 선택이 아니라 **조인 키**임 — 수치 근거 (29번) + +암호화 기준자료도 **스키마는 평문**이라 뼈대를 잴 수 있었음. 잰 결과: + +``` +자재 사전 JDCODE_M.BSC 코드 57,580건 ↔ 명칭 1,761종 + 같은 이름이 규격만 달리해 평균 33건씩 갈림 + 규격 칸 110자 · 단위 사전 59종(자재)·130종(통합) · 분류 4~5단 +``` + +⇒ **이름만으로는 원리적으로 못 맞춤.** 「이름으로 맞추고 규격으로 확인」이 아니라 +**(이름 + 규격) 한 쌍이 키**임. 못 맞춘 497줄의 뿌리가 이 수치로 설명됨. + ### 쓰는 법 1. `size` 칸을 **반드시 같이 볼 것** — 3장 ③ 「규격이 없으면 매칭 성공이 아님」이 여기서 걸림 @@ -310,6 +323,37 @@ STmate 코드는 `B00001`·`M00042` 꼴로 **층 글자 + 다섯 자리**이고 --- +## 12. 납품 규격 — 조달청 공사원가호환규정 (근거, 범위 밖) + +`System\Valid_Xml.vsl` 이 **조달청 공사원가통합관리시스템 공개 규격 V1.0.5** 전문이었음 +(1,429줄). **STmate 고유가 아니라 국가 표준 납품 규격**이라 우리가 그대로 따르면 됨. +사본 — `…/STmate 분석/30_원자료/조달청_공사원가호환규정.xsd` · 해설 `20_분석/30_조달청_호환규정_XSD.md` + +### ⭐ 우리 계약 둘을 국가 규격이 뒷받침함 + +| 우리가 정한 것 | 규격이 요구하는 것 | +| --- | --- | +| **산출식을 데이터로 저장**(3장 구조물도) | 제경비항목에 **`적용산식`·`적용산식설명`을 실어 보낼 것** | +| 제외 구역을 따로 셈 | `직접공사비정보` 에 **`제요율적용제외공종_금액`** 이 박혀 있음 | + +⇒ 「식을 칸에 담는다」는 우리 취향이 아니라 **납품 요구사항**임. 랩탑 서브에게 이 근거를 줄 것. + +### ⚠ 손료가 네 갈래인데 우리는 하나뿐 + +``` +규격 E0-주간 · E1-야간 · E2-주야간2교대 · E3-주야간3교대 +우리 E0 만 (17번 §4 손료율 25개 장비) +``` + +⇒ **야간·교대 손료는 품셈에서 가져와야 함.** 6장 일감으로 세웠음. + +(기계경비 산출 구간 `H0-일반 · H1-터널 · H2-장대터널 · H3~H5 준설선` 은 +우리가 UI 에서 캔 `RX1NM~RX4NM` 과 **하나도 안 틀리고 같음** — 검증 통과로 봄.) + +⚠ **XSD 납품 기능 자체는 지금 판의 범위 밖임.** 여기는 **근거**로만 둠. + +--- + ## 10. 고칠 때 이 문서는 **브레인이 관리함.** 코드 창이 계약을 바꿔야 하면 **고치지 말고 브레인에게 알릴 것** — diff --git a/resources/knowledge/original/경쟁사 프로그램/STmate 분석/20_분석/00_분석_요약과_읽는법.md b/resources/knowledge/original/경쟁사 프로그램/STmate 분석/20_분석/00_분석_요약과_읽는법.md index 8a4ff0c1..8c514568 100644 --- a/resources/knowledge/original/경쟁사 프로그램/STmate 분석/20_분석/00_분석_요약과_읽는법.md +++ b/resources/knowledge/original/경쟁사 프로그램/STmate 분석/20_분석/00_분석_요약과_읽는법.md @@ -35,6 +35,8 @@ | 26 편집기와 사이클식 | **`Cm` 54꼴·품셈 인자·식 편집기·낙찰율** — 계약/실행/기성 단계 발견 | (저장소 파일) | | 27 계약·실행·기성 단계 | **설계 뒤 네 단계** — 낙찰율 3갈래, 기성은 계약제잡비율, 실행 차액보정 | (저장소 파일) | | 28 변환·수량산출·식언어 | **식 변수의 대상/저장 비목** · 수량산출서 셀별명 · 조달청 변환 · 직종 106종 | (저장소 파일) | +| 29 암호화자료의 뼈대 | **값 없이 구조만** — 스키마 평문, 카탈로그 규모, 분류 4~5단, 단위 59~130종 | (저장소 파일) | +| 30 조달청 호환규정 XSD | ⭐ **국가 표준 납품 규격 전문** — 문서 뼈대·제경비 적용산식·기계경비 산출 | (저장소 파일) | > **정본은 저장소 파일이다** — `resources/knowledge/original/경쟁사 프로그램/STmate 분석/20_분석/`. > 위 Orca 링크는 보기용이며, 문서를 지식DB 자리로 옮기면서 주소가 한 번 바뀌었다. @@ -124,6 +126,17 @@ | 조달청 호환 | 몰랐음 | **`Conv_Jodal_ST8`·`Conv_ST8_XML` 양방향** | 28번 §3 | | 노임 직종 | 코드만 | **직종 사전 106종** 평문 | 28번 §3.1 | | 암호화 파일 | 2개 | ⚠ **3개** — `EQSYNC.DBF` 추가 | 28번 §5 | +| 암호화 자료의 **스키마** | 「못 읽음」으로만 | **전부 평문** — 필드명·형식·길이 | 29번 §1 | +| 자원 사전 구조 | 몰랐음 | **분류3단+`JDCODE`17자리+명칭+표준명칭+규격110자+단위** | 29번 §1.1 | +| 분류 트리 | 몰랐음 | **4~5단 깊이**, 부모 포인터, 형제 순서 | 29번 §1.2·§3.1 | +| 카탈로그 규모 | 건수만 | **명칭 1,761종 : 자재 57,580건** — 규격이 난관 | 29번 §3 | +| 단위 사전 | 몰랐음 | **59종(자재)·130종(통합)** | 29번 §3 | +| 표준시장단가 | 건수만 | **노무만 요율 조정** (`RATE_M`·`RATE_S` 빈칸) | 29번 §3.2 | +| XML 스키마 | ⛔ 「못 얻음」이라 적었음 | **정정 — `Valid_Xml.vsl` 에 XSD 전문** | 30번 | +| 납품 규격 | STmate 고유로 봄 | **조달청 공개 국가 표준** V1.0.5 — 그대로 따름 | 30번 §1 | +| 식을 데이터로 저장 | 우리 계약 | **규격이 `적용산식`·`적용산식설명`을 요구** | 30번 §3 | +| 노임 환산 갈래 | 화면 문자열 | **`H0`~`H5` 규격 코드** (일반·터널·장대터널·준설선 3종) | 30번 §4 | +| 손료 산출 | 한 갈래로 봄 | ⚠ **주간/야간/2교대/3교대 네 갈래** — 뽑은 25개는 `E0` 뿐 | 30번 §4 | 분석일: 2026-09-13 대상: `C:\경영정보(주)` diff --git a/resources/knowledge/original/경쟁사 프로그램/STmate 분석/20_분석/28_변환_수량산출_식언어.md b/resources/knowledge/original/경쟁사 프로그램/STmate 분석/20_분석/28_변환_수량산출_식언어.md index b03e3a97..00ed0433 100644 --- a/resources/knowledge/original/경쟁사 프로그램/STmate 분석/20_분석/28_변환_수량산출_식언어.md +++ b/resources/knowledge/original/경쟁사 프로그램/STmate 분석/20_분석/28_변환_수량산출_식언어.md @@ -85,7 +85,7 @@ ST_Conv_Mode1/2/3 · ST_Conv_Data · ST_Copy_Data ⇒ **납품 호환 경로가 프로그램 안에 있다.** `호환규정코드`가 `wM_Mrg_BSC`의 매칭 키 1순위였던 것(20번 §2)과 이어진다. -XML **스키마는 문자열로 안 남는다** — 실행 중에 조립한다. 태그 이름을 정적으로는 못 얻는다. +> ⛔ **이 판정은 틀렸다. 30번에서 정정했다.** 스키마는 `System\Valid_Xml.vsl` 에 **XSD 파일로 있다**(1,429줄, 조달청 공사원가호환규정 V1.0.5). ### 3.1 노임 직종 사전 106종 @@ -163,7 +163,7 @@ EQSYNC.DBF 54행 × 3열 (2.9 KB) | 무엇 | 상태 | |---|---| -| XML 스키마 태그 | 실행 중 조립 — 정적으로 못 얻음 | +| ~~XML 스키마 태그~~ | ✅ **해결** — `Valid_Xml.vsl` XSD 전문 (30번) | | `EQSYNC` 내용 | 암호화 | | 수량산출서 파일 형식 | 표본 0건 (`QCODE` 전부 빈 표) | | 조달청 변환 결과물 | 표본 0건 | diff --git a/resources/knowledge/original/경쟁사 프로그램/STmate 분석/20_분석/29_암호화자료의_뼈대.md b/resources/knowledge/original/경쟁사 프로그램/STmate 분석/20_분석/29_암호화자료의_뼈대.md new file mode 100644 index 00000000..3a40d344 --- /dev/null +++ b/resources/knowledge/original/경쟁사 프로그램/STmate 분석/20_분석/29_암호화자료의_뼈대.md @@ -0,0 +1,121 @@ +# 암호화 자료의 뼈대 — 값을 못 읽어도 구조는 잰다 + +점검일: 2026-09-13 +전제: **가격 값은 중요하지 않다.** 반기마다 바뀌고 Aislo는 자기 원천을 따로 구독한다. 중요한 것은 **코드 체계·분류 깊이·명칭/규격/단위 자리·카탈로그 규모**다. +재현 스크립트: `40_스크립트/encrypted_schema_probe.py` + +## 1. ⭐ 스키마는 암호화돼 있지 않다 + +DBF 헤더(필드 이름·형식·길이)는 **전부 평문**이다. 막힌 것은 레코드 본문뿐이다. + +### 1.1 `JDCODE_*.BSC` — 자원 코드 사전 (12필드) + +```text +TYP_1(C16) TYP_2(C10) TYP_(C11) 분류 3단 +JDCODE(C17) ⭐ 자원 코드 — 17자리 +NAME(C60) SIZE(C110) UNIT(C10) 명칭 / 규격 / 단위 +EQCODE(C11) 시설코드 +GNCODE(C11) 그룹코드 +TYP_STN(L1) 표준 여부 (논리) +PSCODE(C11) 연계 코드 +ST_NAME(C70) ⭐ 표준 명칭 (별도 칸) +``` + +**`NAME` 말고 `ST_NAME`(표준 명칭)이 따로 있다.** 물가지 표기와 표준 표기를 나눠 담는다 — 자원 축 매칭에 그대로 쓸 개념이다. + +`SIZE`가 **110자**다. 규격 문자열이 길다는 뜻이고, 매칭을 규격까지 걸면 어려운 까닭이다. + +### 1.2 `GRPCOD_*.BSC` — 분류 트리 (7필드) + +```text +TYP_1(C16) TYP_(C10) 분류 갈래 +CUR_COD(C10) 현재 노드 코드 +ROT_COD(C10) 상위 노드 코드 ← 부모 포인터 +LEV(N2) ⭐ 깊이 +NAME(C80) 분류 이름 +ORD(C5) 형제 순서 +``` + +**`CUR_COD` / `ROT_COD` / `LEV` 의 부모–자식 트리**다. 구조가 그대로 드러난다. + +### 1.3 `*.CSTA` — 표준시장단가 (32필드) + +```text +CODE(C6) · NAME(C50) · SIZE(C60) · UNIT(C10) +CODEA(C11) · JDCODE(C17) · BQCODE(C11) · EQCODE(C11) · PG_J(C12) +AAMT1~4 · MAMT1/2 · LAMT1/2 · SAMT1/2 · TAMT1/2 금액 두 벌 +MAMT1_R · LAMT1_R · SAMT1_R · MAMT2_R · LAMT2_R · SAMT2_R _R 계열 +RATE_L · RATE_M · RATE_S 비목별 요율 +TYP_C(C1) · EDT_DATE(C12) +``` + +⇒ 표준시장단가도 **재·노·경 세 성분으로 나뉘고 두 벌(1/2)**을 든다. `_R` 계열과 `RATE_*`가 따로 있는 것이 27번 §2의 「노임외 재료비로 적용 / 경비로 적용」(`ACTTYP`)과 이어진다. + +## 2. 암호는 **오프셋별 고정 변환**이다 + +같은 필드 안에서 **같은 평문은 늘 같은 암호문**이 된다. `JDCODE_M.BSC` 57,580행에서 `EQCODE`가 **단 한 가지 암호문**인 것이 증거다(= 전 행이 빈칸). + +⚠ 다만 **필드가 다르면 키가 다르다.** `GRPCOD_Z`에서 `ROT_COD`가 다른 행의 `CUR_COD`와 일치하는 비율이 **0.0%**다. 같은 코드라도 자리가 다르면 다른 암호문이 된다. + +⇒ **필드 안 동일성만 셀 수 있고, 필드 사이를 잇지는 못한다.** 트리의 부모–자식 연결은 복원 불가다. + +②트랙 `original/원가계산/STmate/STC_난독화_상태.md` 의 「오프셋별 고정 변환」 판정과 같은 결론이고, 이번에 **기준자료(BSC/CSTA)에서도 성립**함을 확인했다. + +## 3. 값을 못 읽어도 잰 것 — 카탈로그 규모 + +| 파일 | 행수 | 서로 다른 `JDCODE` | `NAME` | `SIZE` | `UNIT` | +|---|---:|---:|---:|---:|---:| +| `JDCODE_L` 노무 | 460 | **460** | 371 | 23 | — | +| `JDCODE_M` 자재 | **57,580** | **57,580** | **1,761** | 57,069 | **59** | +| `JDCODE_S` 경비 | 851 | 850 | 219 | 654 | 9 | +| `JDCODE_X` 중기 | 833 | 831 | 210 | 657 | — | +| `JDCODE_Z` 통합 | **33,927** | 33,927 | **6,170** | 18,247 | **130** | + +읽는 법 몇 가지. + +- **자재 57,580건이 전부 고유 코드**이고, **명칭은 1,761종**뿐이다. 같은 이름이 규격만 달리해 평균 **33건**씩 갈린다 — 규격 매칭이 자원 축의 실제 난관임을 수치로 보여 준다. +- **단위는 59종**(자재) · **130종**(통합). 단위 사전 크기를 미리 알 수 있다. +- `JDCODE_Z`는 `TYP_1`·`TYP_2`가 **각 6종**으로 갈린다 — 통합 사전이 6갈래 분류를 가진다. +- `JDCODE_S`·`JDCODE_X`는 `EQCODE`·`PSCODE`·`ST_NAME`이 **750~800종**으로 채워져 있다(자재는 비어 있음). **경비·중기는 시설코드와 표준명칭을 쓰고 자재는 안 쓴다.** + +### 3.1 분류 트리 규모 + +| 파일 | 행수 | `CUR_COD` 고유 | `ROT_COD` 고유 | `LEV` 값 | `NAME` 고유 | +|---|---:|---:|---:|---:|---:| +| `GRPCOD_E` | 2,539 | 2,277 | 596 | **4단** | 2,221 | +| `GRPCOD_Z` | 12,125 | 12,125 | 5,792 | **5단** | 4,295 | + +⇒ **분류가 4~5단 깊이**이고, 상위 노드가 596 / 5,792개, 한 부모의 자식이 최대 22개다. 이름은 4,295종이지만 행이 12,125이므로 **같은 이름이 여러 갈래에 걸린다.** + +### 3.2 표준시장단가 + +```text +4,921행 · CODE 4,921 고유 · NAME 488종 · SIZE 2,072종 · UNIT 22종 +BQCODE 28종 · EQCODE 89종 · PG_J 257종 · TYP_C 3종 +금액 AAMT1/TAMT1 4,719종 · MAMT 3,294 · LAMT 4,671 · SAMT 1,945 +RATE_L 72종 · LAMT1_R / LAMT2_R 각 71종 +비어 있는 필드: MAMT1_R · SAMT1_R · MAMT2_R · SAMT2_R · AAMT3 · AAMT4 · RATE_M · RATE_S · EDT_DATE +``` + +⇒ **`RATE_L`(노무 요율)만 72종으로 채워져 있고 `RATE_M`·`RATE_S`는 비어 있다.** 표준시장단가는 **노무비만 요율로 조정**한다는 뜻이고, 설치 폴더의 파일명 `표준시장[노임외 재료비적용]`·`[노임외 경비적용]`(21번 §4)과 정확히 맞는다. + +`BQCODE` 28종 = **공종 분류 28갈래**, `EQCODE` 89종 = 시설 89종. + +## 4. 이로써 Aislo 가 얻는 것 + +| 무엇 | 얻은 것 | +|---|---| +| 자원 코드 자릿수 | `JDCODE` **17자리**, 그룹 `GNCODE`·시설 `EQCODE` 각 11자리 | +| 자원 사전 구조 | 분류 3단 + 코드 + 명칭 + **표준명칭 별도** + 규격(110자) + 단위 | +| 분류 트리 | **4~5단 깊이**, 부모 포인터 방식, 형제 순서 칸 | +| 카탈로그 규모 | 자재 57,580 · 통합 33,927 · 중기 833 · 경비 851 · 노무 460 | +| 단위 사전 | **59~130종** | +| 매칭 난이도 | **명칭 1,761종 : 코드 57,580건** — 규격으로 갈린다 | +| 표준시장단가 | 재·노·경 세 성분, **노무만 요율 조정** | + +⇒ **가격을 못 읽는 것이 설계에 걸림돌이 되지 않는다.** 축을 세우는 데 필요한 것은 전부 나왔다. + +## 5. 끝내 못 얻는 것 + +`JDCODE` 값 자체 · `NAME`·`SIZE`·`UNIT` 문자열 · 가격. +⇒ **이름·코드 목록이 필요하면 프로젝트 파일 쪽에서 얻는다** — `COSTN.DBF`는 평문이고 실무 7건에서 894개를 뽑아 뒀다(`30_원자료/stmate_code_name_list.csv`). diff --git a/resources/knowledge/original/경쟁사 프로그램/STmate 분석/20_분석/30_조달청_호환규정_XSD.md b/resources/knowledge/original/경쟁사 프로그램/STmate 분석/20_분석/30_조달청_호환규정_XSD.md new file mode 100644 index 00000000..ab68467f --- /dev/null +++ b/resources/knowledge/original/경쟁사 프로그램/STmate 분석/20_분석/30_조달청_호환규정_XSD.md @@ -0,0 +1,149 @@ +# 조달청 공사원가호환규정 XSD — 국가 표준 납품 규격 + +발견일: 2026-09-13 +계기: 최종 빈칸 점검에서 **확장자 기준으로 한 번도 안 연 파일 종류**를 훑다가 나옴. +⛔ **28번 §3의 「XML 스키마는 실행 중 조립이라 정적으로 못 얻음」은 틀렸다.** 스키마가 **파일로 있다.** + +원본: `C:\경영정보(주)\System\Valid_Xml.vsl` (57.4 KB · 1,429줄) +재현 스크립트: `40_스크립트/xsd_parse.py` · 사본 `30_원자료/조달청_공사원가호환규정.xsd` + +## 1. 무엇인가 + +```xml + + 이 문서는 공사원가호환규정 V1.0.4 XML스키마이다. 최종수정일 2022-10-06 + … 1.0.5 건축공사의 사업내용 정보 추가 (2022-11-28) + +``` + +**조달청 공사원가통합관리시스템(`npccs.g2b.go.kr`)이 발표하는 공개 규격**이다. STmate 고유 포맷이 아니라 **국가 표준**이므로 Aislo가 그대로 따라 쓰면 된다. + +같은 폴더에 `호환규정코드(2026_09_01)` 안내문도 있다. + +```text +호환규정코드 2026년 9월 1일 조달청 발표분 반영 +조달청 공사원가통합관리시스템 https://npccs.g2b.go.kr 발표 +``` + +⇒ **STmate는 이 규격을 따라 `Conv_Jodal_ST8`·`Conv_ST8_XML`(28번 §3)로 변환한다.** 규정 코드는 반기마다 갱신된다. + +버전 이력이 그대로 남아 있다 — `1.0.0` → `1.0.5`. 내역공종코드 길이가 **16 → 20자**로 늘어난 것(2017-04-19)도 적혀 있다. + +## 2. 문서 뼈대 + +```text +공사원가호환규정 + ├ 스키마버전 1.0.0 ~ 1.0.5 + ├ 작성정보 작성업체명 · 프로그램명 · 프로그램버전 · 파일제작일 + │ · 파일검증여부 · 건축공사사업내용작성여부 + ├ 공사정보 공사관리번호 · 주공사 · 공사명 · 비고 + │ · 기계경비산출 · 건축공사사업내용 + └ 공사내역서 + ├ 공사제경비 → 제경비항목 + └ 단일공사내역서 + ├ 단일공사정보 (공사구분 · 공사설명 · 전역변수) + ├ 내역서 → 공종 → 세부공종 + ├ 일위대가 → 일위대가상세 + ├ 중기단가산출 + ├ 기계경비 → 기계경비상세 + ├ 자재 → 조사처별단가 + ├ 노무 + ├ 경비항목 → 조사처별단가_경비 + ├ 실적단가 + └ 제경비 +``` + +**우리가 판 STmate 구조와 한 겹씩 대응한다.** + +| 호환규정 | STmate | +|---|---| +| 일위대가 / 일위대가상세 | `B` 코드 호표 (17번) | +| 중기단가산출 | `D` 코드 (18번 §2.2) | +| 기계경비 / 기계경비상세 | 중기사용료 호표 (17번 §3) | +| 자재 · 노무 · 경비항목 | `M` · `L` · `S` 코드 | +| 실적단가 | 표준시장단가 `CSTA` (29번 §1.3) | +| 제경비항목 | 간접비 (25번 §2) | +| 내역서 → 공종 → 세부공종 | `BOQ11` 계층 (16번 §4.1) | + +## 3. ⭐ 제경비 항목이 **적용산식을 문자열로** 싣는다 + +```text +제경비항목정보 : 세부공종코드 · 집계항목여부 · 비목명 + · 적용산식 · 적용요율 · 추가금액 · 산출금액 · 적용산식설명 +``` + +⇒ **국가 규격 자체가 「밑수 산식을 글로 적어 함께 보내라」고 정한다.** 25번 §2의 간접비 식 템플릿 26개와 같은 요구다. 우리가 「식을 데이터로 저장하라」고 세운 계약이 **납품 규격과 맞아떨어진다.** + +`직접공사비정보`도 따로 있다. + +```text +직접공사비_재료비 · 직접공사비_노무비 · 직접공사비_경비 +· 미확정설계공종_금액 · 제요율적용제외공종_금액 · 관급자재공종_금액 +``` + +⇒ **「제요율적용제외공종」이 규격에 못 박혀 있다** — 16번 §4.2의 `C*` 제외금액 구역, 27번 §2.3의 낙찰율 적용제외와 같은 개념이 **국가 표준에도 있다.** + +## 4. ⭐ 기계경비 산출 규격 — 우리가 캔 것과 정확히 맞는다 + +```text +기계경비산출형식 : 환율 · 시간당손료산출 · 시간당노임산출 + +환율형식 미국_1USD · 유럽연합_1EUR · 일본_100엔 +시간당손료산출 E0-주간 · E1-야간 · E2-주야간2교대 · E3-주야간3교대 +시간당노임산출 H0-일반구간 · H1-터널구간 · H2-장대터널 + · H3-펌프준설선 · H4-그래브준설선 · H5-버킷준설선 · H_-사용자정의 +``` + +**22번 §3.4에서 `wM_Set_Sys`의 `cmBx_Sel_RX`가 「계수 ; 터널구간 / 계수-1 ; 장대터널 / 계수-2 ; 펌프준설선 / 계수-3 ; GRAB준설선 / 계수-4 ; 버켓준설선」이라 한 것과 하나도 안 틀리고 같다.** + +⇒ `Start8x.Ini`의 `RXXNM`·`RX1NM`~`RX4NM`(20번 §2.5)이 **규격의 `H0`~`H5` 자리**다. 18번 §2.1의 노임 환산식 `1/8*16/12*25/20`이 `H0-일반구간`에 해당한다. + +`환율형식`도 `EXNM_`·`EXEUR`·`EXYEN`(18번 §2.3)과 그대로 맞는다. + +⚠ **`시간당손료산출`이 주간/야간/2교대/3교대 네 갈래**다 — 우리가 17번 §4에서 뽑은 손료율 25개는 **`E0-주간` 한 갈래**일 뿐이다. + +## 5. 열거형 — 코드 목록이 규격에 박혀 있다 + +| 열거 | 개수 | 값 | +|---|---:|---| +| **주공사** | 9 | 토목 · 건축 · 설비 · 전기 · 통신 · 조경 · 소방 · 문화재수리 · **국가유산수리** | +| **공사현장지역코드** | **290** | 행정구역 코드 (`11110` 서울 종로 …) | +| 인도조건구분 | 40 | 일반 · 납품장소도 · 상차도 · 하차도 · 현장설치도 · 생산공장도 … | +| 시설물유형 | 27 | 일반청사 · 대형청사 · 초중고 · 대학교 · 도서관 · 체육시설 … | +| 지역구분 | 3 | 비혼잡(일반) · 혼잡(특수) · **산간도서(특수)** | +| 구조형식 | 4 | 철골조 · 철골철근콘크리트조 · 철근콘크리트조 · 기타 | +| 내진등급 | 3 | 특등급 · 1등급 · 2등급 | +| 에너지효율등급 | 5 | `1+++` ~ 7등급 · 해당 없음 | +| 녹색건축인증 | 5 | 최우수 · 우수 · 우량 · 일반 · 해당 없음 | +| 철거공사 포함여부 · BF인증 · 신재생에너지 · 지반형태 · 흙막이 | 각 2 | | + +**`주공사`에 `문화재수리공사`와 `국가유산수리공사`가 둘 다 있다** — 10번 §4에서 2020-05-27에 국가유산 열이 붙은 것과 이어진다(명칭 개정 과도기). + +**`지역구분`의 `산간도서지역(특수)`** 은 임도에 바로 걸린다. + +## 6. 건축공사 사업내용 36요소 + +```text +연면적 · 대지면적 · 건축면적 · 조경면적 · 지하층연면적 · 용적률 · 건폐율 +기준층층고 · 최고높이 · 최대지하층수 · 최대지상층수 · 공사일수 · 공사현장 … +``` + +건축 전용이라 임도와 직접 관계는 없으나, **규격이 공사 종류별로 사업내용을 따로 요구**한다는 점은 알아 둘 것. + +## 7. 이 발견이 바꾸는 것 + +| 항목 | 이전 | 지금 | +|---|---|---| +| XML 스키마 | ⛔ 「실행 중 조립이라 못 얻음」 | ✅ **파일로 있음** — XSD 1,429줄 전문 | +| 납품 규격 | STmate 고유로 봄 | ✅ **조달청 공개 국가 표준** — 그대로 따라 쓰면 됨 | +| 식을 데이터로 | 우리 계약 | ✅ **규격이 `적용산식`·`적용산식설명`을 요구** | +| 제외공종 | STmate 구현으로 봄 | ✅ **규격에 `제요율적용제외공종_금액`** | +| 노임 환산 갈래 | 화면 문자열로만 | ✅ **`H0`~`H5` 규격 코드** | +| 손료 산출 | 한 갈래로 봄 | ⚠ **주간/야간/2교대/3교대 네 갈래** — 우리가 뽑은 25개는 `E0` 뿐 | +| 공사 종류 | 임도 중심 | ✅ **9종 + 지역코드 290** | + +## 8. 남은 것 + +1. `30_원자료/조달청_공사원가호환규정.xsd` 로 사본을 떠 뒀다. **최신판은 `npccs.g2b.go.kr` 에서 직접 받을 것** — 이 사본은 V1.0.5(2022-11-28)이다. +2. 세부공종 28요소·일위대가상세 21요소의 **필드별 뜻**은 아직 안 풀었다. +3. 손료 `E1`~`E3`(야간·교대) 계수는 STmate 자료에 없다 — 품셈에서 가져와야 한다. diff --git a/resources/knowledge/original/경쟁사 프로그램/STmate 분석/30_원자료/조달청_XSD_구조.txt b/resources/knowledge/original/경쟁사 프로그램/STmate 분석/30_원자료/조달청_XSD_구조.txt new file mode 100644 index 00000000..7bf50426 --- /dev/null +++ b/resources/knowledge/original/경쟁사 프로그램/STmate 분석/30_원자료/조달청_XSD_구조.txt @@ -0,0 +1,56 @@ +### 조달청 공사원가호환규정 XSD + complexType 32개 · simpleType 21개 + +■ complexType (구조 정의) + 공사원가호환규정형식 요소 4 스키마버전, 작성정보, 공사정보, 공사내역서 + 공사정보형식 요소 6 공사관리번호, 주공사, 공사명, 비고, 기계경비산출, 건축공사사업내용 + 건축공사사업내용형식 요소 36 연면적, 대지면적, 건축면적, 조경면적, 지하층연면적, 용적률, 건폐율, 기준층층고, 최고높이, 최대지하층수, 최대지상층수, 공사일수, 공사현장 + 작성정보형식 요소 6 작성업체명, 프로그램명, 프로그램버전, 파일제작일, 파일검증여부, 건축공사사업내용작성여부 + 실적단가형식 요소 11 항목구분, 공종구분, 실적공사비코드, 품명, 규격, 단위, 재료비단가, 노무비단가, 경비단가, 비고, 기타 + 노임산출근거형식 요소 4 산출수량, 노임별공량, 노임할증, 공량품셈근거 + 일위대가상세형식 요소 21 자원구분, 자원코드, 계산제외, 품명, 규격, 단위, 수량, 재료비단가, 노무비단가, 경비단가, 재료비, 노무비, 경비, 관급, 손료코드군, 손 + 일위대가형식 요소 12 일위대가코드, 번호, 품명, 규격, 단위, 재료비, 노무비, 경비, 비목구분, 비고, 기타, 일위대가상세 + 중기단가산출형식 요소 12 중기단가산출코드, 번호, 품명, 규격, 단위, 재료비, 노무비, 경비, 비목구분, 비고, 중기단가산출식, 기타 + 기계경비형식 요소 13 기계경비코드, 번호, 품명, 규격, 단위, 작업시간, 작업장소, 재료비, 노무비, 경비, 비고, 기타, 기계경비상세 + 자재형식 요소 9 자재코드, 품명, 규격, 단위, 재료비단가, 인도조건구분, 비고, 기타, 조사처별단가 + 노무형식 요소 7 노무코드, 품명, 규격, 단위, 노무비단가, 비고, 기타 + 경비항목형식 요소 16 경비항목코드, 경비구분, 품명, 규격, 단위, 통화구분, 재료비단가, 노무비단가, 경비단가, 외화경비단가, 상각비계수, 정비비계수, 관리비계수, + 조사처별단가형식 요소 5 상호, 전화번호, 재료비단가, 인도조건구분, 비고 + 조사처별단가형식_경비 요소 7 상호, 전화번호, 재료비단가, 노무비단가, 경비단가, 인도조건구분, 비고 + 공사내역서형식 요소 2 공사제경비, 단일공사내역서 + 단일공사내역서형식 요소 10 단일공사정보, 내역서, 일위대가, 중기단가산출, 기계경비, 자재, 노무, 경비항목, 실적단가, 제경비 + 공종형식 요소 11 내역서번호, 공종구분, 계산제외, 공종번호, 공종명, 재료비, 노무비, 경비, 비고, 기타, 세부공종 + 세부공종형식 요소 28 세부공종코드, 자원구분, 계산제외, 세부공종번호, 세부공종명, 규격, 단위, 수량, 재료비단가, 노무비단가, 경비단가, 재료비, 노무비, 경비, + 단일공사정보형식 요소 3 공사구분, 공사설명, 전역변수 + 전역변수형식 요소 2 변수명, 실수값 + 개별노임산출형식 요소 2 노무코드, 공량 + 직접공사비정보 요소 6 직접공사비_재료비, 직접공사비_노무비, 직접공사비_경비, 미확정설계공종_금액, 제요율적용제외공종_금액, 관급자재공종_금액 + 제경비항목정보 요소 8 세부공종코드, 집계항목여부, 비목명, 적용산식, 적용요율, 추가금액, 산출금액, 적용산식설명 + 공사제경비형식 요소 1 제경비항목 + 기계경비상세 요소 22 자원구분, 자원코드, 계산제외, 품명, 규격, 단위, 수량, 재료비단가, 노무비단가, 경비단가, 재료비, 노무비, 경비, 손료코드군, 손료계산비 + 사용자정의_시간당노임산출정보 요소 2 사용자정의코드, 노임산출 + 노임산출형식 요소 3 산출식, 계산값, 적용구분 + 기계경비산출형식 요소 3 환율, 시간당손료산출, 시간당노임산출 + 환율형식 요소 3 미국_1USD, 유럽연합_1EUR, 일본_100엔 + 시간당손료산출형식 요소 4 E0-주간, E1-야간, E2-주야간2교대, E3-주야간3교대 + 시간당노임산출형식 요소 7 H0-일반구간, H1-터널구간, H2-장대터널, H3-펌프준설선, H4-그래브준설선, H5-버킷준설선, H_-사용자정의 + +■ 열거형 (코드 목록) + 인도조건구분형식 40개: 일반, 납품장소도, 납품장소상차도, 납품장소하차도, 납품장소입고도, 현장설치도, 생산공장도(상차제외), 최기역레일도, 운반구상차도, 분공장도(상차제외), 수요기관 부 + 열거형 1개 + +■ 인라인 열거 40곳 (앞 14) + 스키마버전 6개: 1.0.5, 1.0.4, 1.0.3, 1.0.2, 1.0.1, 1.0.0 + 주공사 9개: 토목공사, 건축공사, 설비공사, 전기공사, 통신공사, 조경공사, 소방공사, 문화재수리공사, 국가유산수리공사 + 공사현장지역코드 290개: 11110, 11140, 11170, 11200, 11215, 11230, 11260, 11290, 11305, 11320, 11350, 113 + 시설물유형 27개: 일반청사, 대형청사, 일반형초중고, 복합형초중고, 강의형대학교, 복합형대학교, 일반형수련시설, 복합형수련시설, 도서관, 야외형체육시설, 복합형체 + 철거공사포함여부 2개: 철거공사 포함, 철거공사 미포함 + 지역구분 3개: 비혼잡지역(일반), 혼잡지역(특수), 산간도서지역(특수) + 지반공사.지반형태 2개: 일반지반, 특수지반 + 지반공사.흙막이공사 2개: 흙막이공사가 없는 경우, 흙막이공사가 있는 경우 + 구조형식 4개: 철골조, 철골철근콘크리트조, 철근콘크리트조, 기타 + 내진등급 3개: 내진설계특등급, 내진설계1등급, 내진설계2등급 + 건축물인증.신재생에너지 2개: 대상시설 해당 있음, 대상시설 해당 없음 + 건축물인증.에너지효율등급 5개: 1+++ 등급, 1등급 ~ 1++등급, 2등급 ~ 4등급, 5등급 ~ 7등급, 해당 없음 + 건축물인증.녹색건축인증 5개: 최우수, 우수, 우량, 일반, 해당 없음 + 건축물인증.BF인증 2개: 해당 있음, 해당 없음 diff --git a/resources/knowledge/original/경쟁사 프로그램/STmate 분석/30_원자료/조달청_공사원가호환규정.xsd b/resources/knowledge/original/경쟁사 프로그램/STmate 분석/30_원자료/조달청_공사원가호환규정.xsd new file mode 100644 index 00000000..c4751112 --- /dev/null +++ b/resources/knowledge/original/경쟁사 프로그램/STmate 분석/30_원자료/조달청_공사원가호환규정.xsd @@ -0,0 +1,1429 @@ + + + + 이 문서는 공사원가호환규정 V1.0.4 XML스키마이다. 최종수정일 2022-10-06 +- 코드 규정 관련 보완 +- 기 1.0.1 버전 중 내역서의 코드 관련 보완(텍스트 사용에 따른 수정) +- 기 1.0.2 버전 중 작성정보에 파일검증여부추가(G2B업로드에 따른 수정) +- 기 1.0.2 버전 에 표준시장단가 추가. 스키마 버전 1.0.3으로 업그래이드 (2015-07-14) +- 기 1.0.3 버전 에 내역공종코드형식 max length 변경(16->20) (2017-04-19) +- 기 1.0.4 건축공사의 사업내용 정보 추가 (2018-11-29) +- 1.0.5 건축공사의 사업내용 정보 추가 (2022-11-28) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/knowledge/original/경쟁사 프로그램/STmate 분석/30_원자료/호환규정코드_안내.txt b/resources/knowledge/original/경쟁사 프로그램/STmate 분석/30_원자료/호환규정코드_안내.txt new file mode 100644 index 00000000..2cd47bed --- /dev/null +++ b/resources/knowledge/original/경쟁사 프로그램/STmate 분석/30_원자료/호환규정코드_안내.txt @@ -0,0 +1,3 @@ +호환규정코드 2026년 9월 1일 조달청 발표분 반영 + +조달청 공사원가통합관리시스템 https://npccs.g2b.go.kr 발표 \ No newline at end of file diff --git a/resources/knowledge/original/경쟁사 프로그램/STmate 분석/40_스크립트/encrypted_schema_probe.py b/resources/knowledge/original/경쟁사 프로그램/STmate 분석/40_스크립트/encrypted_schema_probe.py new file mode 100644 index 00000000..972169b7 --- /dev/null +++ b/resources/knowledge/original/경쟁사 프로그램/STmate 분석/40_스크립트/encrypted_schema_probe.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +"""암호화 기준자료에서 '값 말고 뼈대'를 건진다 — 스키마·필드·부분 평문 여부.""" +import sys, io, struct, pathlib, collections, re +sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") +R = pathlib.Path(r"C:\경영정보(주)\System") + +def schema(b): + nrec,hlen,rlen=struct.unpack_from("=0xA1: pr+=1 + ratio.append((n, pr/max(tot,1))) + good=[n for n,r in ratio if r>0.9] + print(" 평문 비율 0.9 넘는 필드: %s" % (", ".join(good) if good else "없음")) + print(" 필드별: %s" % " ".join("%s=%.2f"%(n,r) for n,r in ratio)) + print() diff --git a/resources/knowledge/original/경쟁사 프로그램/STmate 분석/40_스크립트/encrypted_structure_probe.py b/resources/knowledge/original/경쟁사 프로그램/STmate 분석/40_스크립트/encrypted_structure_probe.py new file mode 100644 index 00000000..33505e9b --- /dev/null +++ b/resources/knowledge/original/경쟁사 프로그램/STmate 분석/40_스크립트/encrypted_structure_probe.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +"""암호를 풀지 않고 구조만 복원 — 같은 값은 같은 암호문이라는 성질만 쓴다. +값(이름·가격)은 끝내 모르지만 분류 트리·군집 크기·연결 관계는 나온다.""" +import sys, io, struct, pathlib, collections, hashlib +sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") +R=pathlib.Path(r"C:\경영정보(주)\System") + +def table(p): + b=p.read_bytes(); nrec,hlen,rlen=struct.unpack_from("=10))) +t1=collections.Counter(key(r["TYP_1"]) for r in jm) +t2=collections.Counter(key(r["TYP_2"]) for r in jm) +t3=collections.Counter(key(r["TYP_"]) for r in jm) +print("\n 분류 3단 고유 개수: TYP_1 %d · TYP_2 %d · TYP_ %d" % (len(t1),len(t2),len(t3))) +print(" TYP_ 군집 크기 상위 10: %s" % [c for _,c in t3.most_common(10)]) diff --git a/resources/knowledge/original/경쟁사 프로그램/STmate 분석/40_스크립트/final_gap_check.py b/resources/knowledge/original/경쟁사 프로그램/STmate 분석/40_스크립트/final_gap_check.py new file mode 100644 index 00000000..7ebc6ea2 --- /dev/null +++ b/resources/knowledge/original/경쟁사 프로그램/STmate 분석/40_스크립트/final_gap_check.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +"""최종 빈칸 점검 — 아직 한 번도 안 연 파일 종류가 있나.""" +import sys, io, pathlib, struct, collections, re +sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") +R=pathlib.Path(r"C:\경영정보(주)") +COVERED_EXT = {".CPM",".EXE",".BPL",".DLL",".BSC",".CSTA",".STBSC",".DBF",".SMT",".STC", + ".JB_1",".JB_M",".JB_R",".JB_RA",".INI",".MEM",".XLSX"} +kinds=collections.Counter(); other=collections.defaultdict(list) +for p in R.rglob("*"): + if not p.is_file(): continue + e=p.suffix.upper() + kinds[e]+=1 + if e not in COVERED_EXT: other[e].append(p) +print("### `C:\경영정보(주)` 전체 파일 종류") +for e,c in kinds.most_common(): print(" %-10s %3d %s" % (e or "(없음)", c, "✅ 다룸" if e in COVERED_EXT else "·")) +print("\n### 아직 안 다룬 종류의 실제 파일") +for e,ps in sorted(other.items()): + for p in ps[:4]: + try: head=p.open("rb").read(16) + except Exception: head=b"" + print(" %-12s %-30s %7.1f KB 머리=%r" % (e or "(없음)", p.name[:30], p.stat().st_size/1024, head[:10])) diff --git a/resources/knowledge/original/경쟁사 프로그램/STmate 분석/40_스크립트/xsd_parse.py b/resources/knowledge/original/경쟁사 프로그램/STmate 분석/40_스크립트/xsd_parse.py new file mode 100644 index 00000000..53ee712a --- /dev/null +++ b/resources/knowledge/original/경쟁사 프로그램/STmate 분석/40_스크립트/xsd_parse.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +"""조달청 공사원가호환규정 XSD 구조 추출.""" +import sys, io, re, pathlib, collections +import xml.etree.ElementTree as ET +sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") +p=next(pathlib.Path(r"C:\경영정보(주)").rglob("Valid_Xml.vsl")) +t=p.read_text(encoding="utf-8") +NS={"xsd":"http://www.w3.org/2001/XMLSchema"} +root=ET.fromstring(t) +ct=root.findall("xsd:complexType",NS); st=root.findall("xsd:simpleType",NS) +print("### 조달청 공사원가호환규정 XSD") +print(" complexType %d개 · simpleType %d개" % (len(ct), len(st))) +print("\n■ complexType (구조 정의)") +for c in ct: + nm=c.get("name") + els=[e.get("name") or e.get("ref") for e in c.iter("{http://www.w3.org/2001/XMLSchema}element")] + ats=[a.get("name") for a in c.iter("{http://www.w3.org/2001/XMLSchema}attribute")] + print(" %-22s 요소 %2d %s" % (nm, len(els), ", ".join(x for x in els if x)[:80])) + if ats: print(" 속성: %s" % ", ".join(x for x in ats if x)[:80]) +print("\n■ 열거형 (코드 목록)") +n=0 +for s in st: + nm=s.get("name") + en=[e.get("value") for e in s.iter("{http://www.w3.org/2001/XMLSchema}enumeration")] + if not en: continue + n+=1 + print(" %-22s %2d개: %s" % (nm, len(en), ", ".join(en)[:92])) +print(" 열거형 %d개" % n) +# 인라인 열거 +inline=re.findall(r'name="([^"]+)"[^>]*>\s*\s*]*>((?:\s*)+)', t) +print("\n■ 인라인 열거 %d곳 (앞 14)" % len(inline)) +for nm,blk in inline[:14]: + vals=re.findall(r'value="([^"]*)"', blk) + print(" %-22s %2d개: %s" % (nm, len(vals), ", ".join(vals)[:80])) diff --git a/resources/knowledge/original/경쟁사 프로그램/STmate 분석/README.md b/resources/knowledge/original/경쟁사 프로그램/STmate 분석/README.md index 76536cf9..fa9a8475 100644 --- a/resources/knowledge/original/경쟁사 프로그램/STmate 분석/README.md +++ b/resources/knowledge/original/경쟁사 프로그램/STmate 분석/README.md @@ -67,6 +67,8 @@ | 26 | **편집기와 사이클식** — `Cm` 54꼴, 품셈 인자, 식 편집기, 낙찰율 | | 27 | **계약·실행·기성 단계** — 설계 뒤 네 단계의 계산 구조 | | 28 | **변환·수량산출·식언어** — 식 변수 대상/저장, 조달청 변환, 직종 106종 | +| 29 | **암호화자료의 뼈대** — 값 없이 구조만, 카탈로그 규모와 분류 깊이 | +| 30 | ⭐ **조달청 호환규정 XSD** — 국가 표준 납품 규격 전문 | ## 확정된 굵직한 것 diff --git a/resources/tester/test_b07_sheet_key_place.py b/resources/tester/test_b07_sheet_key_place.py index 3fb46931..b1af7fc1 100644 --- a/resources/tester/test_b07_sheet_key_place.py +++ b/resources/tester/test_b07_sheet_key_place.py @@ -12,7 +12,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) -from B07_DesignDetail.B07_DesignDetail_Engine_Standard_Sheet import sheet_key # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_StructureSheet import sheet_key # noqa: E402 def _stone(**options) -> dict: diff --git a/resources/tester/test_b07_standard_figure_reason.py b/resources/tester/test_b07_standard_figure_reason.py index 047bdd70..24815154 100644 --- a/resources/tester/test_b07_standard_figure_reason.py +++ b/resources/tester/test_b07_standard_figure_reason.py @@ -27,7 +27,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardFigure import ( # noq from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardSheet import ( # noqa: E402 build_standard_drawing, ) -from B07_DesignDetail.B07_DesignDetail_Engine_Standard_Sheet import sheet_title # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_StructureSheet import sheet_title # noqa: E402 def _sheet(type_id: str, **options) -> dict: diff --git a/resources/tester/test_b07_standard_sheet_rows.py b/resources/tester/test_b07_standard_sheet_rows.py index be3176af..48b5e272 100644 --- a/resources/tester/test_b07_standard_sheet_rows.py +++ b/resources/tester/test_b07_standard_sheet_rows.py @@ -18,7 +18,7 @@ ROOT = Path(__file__).resolve().parents[2] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -from B07_DesignDetail.B07_DesignDetail_Engine_Standard_Sheet import ( # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_StructureSheet import ( # noqa: E402 build_standard_sheets, ) from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402 diff --git a/resources/tester/test_b07_standard_spec_blinding_weep.py b/resources/tester/test_b07_standard_spec_blinding_weep.py index 7712e818..61278557 100644 --- a/resources/tester/test_b07_standard_spec_blinding_weep.py +++ b/resources/tester/test_b07_standard_spec_blinding_weep.py @@ -24,7 +24,7 @@ if str(ROOT) not in sys.path: from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardFigure import ( # noqa: E402 build_figure, ) -from B07_DesignDetail.B07_DesignDetail_Engine_Standard_Edit import ( # noqa: E402 +from B08_Quantity.B08_Quantity_Engine_StructureSheet_Edit import ( # noqa: E402 BLINDING_CHOICES, EDITABLE_KEYS, clean_spec, @@ -110,7 +110,7 @@ def test_여기서_고치기_단추가_고정_액션_줄에_있다() -> None: assert "specJumpButton.hidden = false" in page assert "specJumpButton.hidden = true" in page # 폼에 버림 칸이 섰다 — 끄는 자리가 화면에 있다(확정 8-2). - form = (ROOT / "B07_DesignDetail" / "B07_DesignDetail_UI_StandardSpec.ts").read_text( + form = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_Spec.ts").read_text( encoding="utf-8" ) assert "blinding_concrete" in form diff --git a/resources/tester/test_b08_structure_sheet_router.py b/resources/tester/test_b08_structure_sheet_router.py new file mode 100644 index 00000000..6243eaa9 --- /dev/null +++ b/resources/tester/test_b08_structure_sheet_router.py @@ -0,0 +1,108 @@ +"""구조물도 창구(B08) — B07 에서 옮긴 뒤 조회·제원 저장이 한 벌로 도는지 (2026-09-13, PLAN 3장 ②). + +겨누는 것 셋 + ① 조회가 장을 냄 — 옛 B07 `/standard-sheets` 자리 + ② ⚠ 기초잡석 두께가 **산출 조건 값을 따름** — 옛 B07 창구는 두께를 안 넘겨 늘 0.2 로 섰음 + (원단위 탭과 구조물도가 같은 구조물에서 다른 값을 냈음) + ③ 제원 저장이 그 장의 개소에 걸리고, 다시 조회하면 새 제원으로 섬 +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +import B08_Quantity.B08_Quantity_Router_Material as material_module # noqa: E402 +import B08_Quantity.B08_Quantity_Router_StructureSheet as router_module # noqa: E402 +from B05_Profile.B05_Profile_Structures_Repository import save_structures # noqa: E402 +from B05_Profile.B05_Profile_Structures_Schema import StructureInstance # noqa: E402 + +PROJECT_ID = "22222222-2222-2222-2222-222222222222" +SHEETS = f"/api/projects/{PROJECT_ID}/quantity/structure-sheets" + + +@pytest.fixture() +def project(tmp_path: Path) -> Path: + root = tmp_path / "project" + root.mkdir() + wall = StructureInstance.model_validate( + { + "type_id": "masonry_wet", + "placement": "interval", + "start_m": 100.0, + "end_m": 110.0, + "options": {"height_m": 2.5, "back_len_cm": 45}, + } + ) + save_structures(str(root), [wall], base_revision=0) + return root + + +@pytest.fixture() +def client(project: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient: + async def fake_root(project_id): + return str(project) + + async def no_route(project_id): + return {} + + monkeypatch.setattr(router_module, "_project_root", fake_root) + monkeypatch.setattr(material_module, "_section_modes", no_route) + monkeypatch.setattr(material_module, "_ground_types", no_route) + app = FastAPI() + app.include_router(router_module.router) + return TestClient(app) + + +def _sheet(client: TestClient) -> dict: + response = client.get(SHEETS) + assert response.status_code == 200, response.text + sheets = response.json()["sheets"] + assert len(sheets) == 1, sheets + return sheets[0] + + +def _unit_amount(sheet: dict, name: str) -> float: + return next(row["unit_amount"] for row in sheet["rows"] if row["name"] == name) + + +def test_조회가_장을_낸다(client: TestClient) -> None: + sheet = _sheet(client) + assert sheet["type_id"] == "masonry_wet" + assert sheet["member_count"] == 1 + assert sheet["billing_unit"] == "m" and sheet["billing_total"] == pytest.approx(10.0) + assert all("spec" in row for row in sheet["rows"]) + + +def test_기초잡석_두께가_산출_조건을_따른다(client: TestClient, project: Path) -> None: + 기본 = _unit_amount(_sheet(client), "기초잡석") + (project / "project_settings.json").write_text( + json.dumps({"quantity": {"rubble_base_thickness_m": 0.3}}), encoding="utf-8" + ) + 바꿈 = _unit_amount(_sheet(client), "기초잡석") + # 기본 0.2 → 0.3 — 버림 폭에 두께 비를 곱하는 식이라 1.5 배. + assert 바꿈 == pytest.approx(기본 * 1.5) + + +def test_제원_저장이_그_장에_걸린다(client: TestClient) -> None: + sheet = _sheet(client) + saved = client.put( + f"{SHEETS}/spec", + json={"sheet_key": sheet["key"], "base_revision": 1, "back_len_cm": "55"}, + ) + assert saved.status_code == 200, saved.text + assert saved.json()["changed"] == 1 + assert _sheet(client)["options"]["back_len_cm"] == 55 + + +def test_없는_장이면_404(client: TestClient) -> None: + response = client.put(f"{SHEETS}/spec", json={"sheet_key": "없는 장", "base_revision": 1}) + assert response.status_code == 404