Merge remote-tracking branch 'origin/dev' into main_laptop_1

This commit is contained in:
2026-09-13 14:53:02 +09:00
29 changed files with 2592 additions and 200 deletions
@@ -260,8 +260,9 @@ export interface StandardSheetsResponse {
}[]; }[];
} }
// 창구는 B08 구조물도 — 2026-09-13 이관(PLAN 3장). 탭 배선 뒤 B07 표준도와 함께 지움.
export function fetchStandardSheets(projectId: string): Promise<StandardSheetsResponse> { export function fetchStandardSheets(projectId: string): Promise<StandardSheetsResponse> {
return requestJson(`/projects/${projectId}/standard-sheets`); return requestJson(`/projects/${projectId}/quantity/structure-sheets`);
} }
/** 장 하나의 제원 저장 — 빈 값(null)은 **그 칸을 지우라**는 뜻이다. */ /** 장 하나의 제원 저장 — 빈 값(null)은 **그 칸을 지우라**는 뜻이다. */
@@ -282,7 +283,7 @@ export function putStandardSheetSpec(
blinding_concrete: string | null; blinding_concrete: string | null;
}, },
): Promise<{ status: string; revision: number; changed: number; notes: string[] }> { ): 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", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify(body),
@@ -37,9 +37,14 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
_text_entity, _text_entity,
polyline_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 ( from B08_Quantity.B08_Quantity_Engine_UnitQuantity import (
STONE_MASONRY, STONE_MASONRY,
face_slope_ratio,
weep_hole_spec, weep_hole_spec,
) )
from common_util.common_util_excavation import ( from common_util.common_util_excavation import (
@@ -61,11 +66,6 @@ SCALE_MM_PER_M = 40.0
#: ⇒ 「직경에서 두께를 내는 식」은 **도메인 판단이라 사용자 몫** — 계획서 4-12 답 대기 줄. #: ⇒ 「직경에서 두께를 내는 식」은 **도메인 판단이라 사용자 몫** — 계획서 4-12 답 대기 줄.
FIGURE_TYPE_IDS: frozenset[str] = frozenset({"masonry_wet", "masonry_dry"}) 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 사용자 지시). 「무엇을 받아야 서는지」까지 적는다. #: (2026-09-09 사용자 지시). 「무엇을 받아야 서는지」까지 적는다.
_NO_FIGURE_REASONS: dict[str, str] = { _NO_FIGURE_REASONS: dict[str, str] = {
@@ -98,31 +98,6 @@ _LABEL_FONT = 4.0
_DIM_FONT = 3.4 _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]: def wall_thickness(height_m: float, back_cm: float) -> tuple[float, float]:
"""(상부, 하부) 두께 — **수량이 쓰는 그 식**(`stone_masonry`)과 같은 상수를 읽는다.""" """(상부, 하부) 두께 — **수량이 쓰는 그 식**(`stone_masonry`)과 같은 상수를 읽는다."""
top_t = back_cm / 100.0 + STONE_MASONRY["thickness_top_add_m"] top_t = back_cm / 100.0 + STONE_MASONRY["thickness_top_add_m"]
@@ -10,7 +10,7 @@
계획평면도가 쓰는 장 나눔 본을 따라야 하고 목록·라우터 네 곳이 함께 움직여야 한다(㉢). 계획평면도가 쓰는 장 나눔 본을 따라야 하고 목록·라우터 네 곳이 함께 움직여야 한다(㉢).
지금은 **값이 눈에 보이는 것**이 먼저라 한 장에 쌓고, 장 나눔은 그 다음이다. 지금은 **값이 눈에 보이는 것**이 먼저라 한 장에 쌓고, 장 나눔은 그 다음이다.
⚠ 값을 여기서 셈하지 않는다 — `_Engine_Standard_Sheet.build_standard_sheets` 가 낸 것을 ⚠ 값을 여기서 셈하지 않는다 — `B08_Quantity_Engine_StructureSheet.build_standard_sheets` 가 낸 것을
글자로 옮길 뿐이다. 셈이 두 벌이 되면 도면과 수량서가 갈린다(CLAUDE.md 5장). 글자로 옮길 뿐이다. 셈이 두 벌이 되면 도면과 수량서가 갈린다(CLAUDE.md 5장).
""" """
@@ -232,18 +232,14 @@ def standard_payload(
⚠ 늦게 부른다(함수 안 import) — B08 은 B05 를 부르고 B05 는 다시 B07 을 부를 수 있어 ⚠ 늦게 부른다(함수 안 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_Router_StructureSheet import project_structure_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
try: try:
structures, names, _skipped = _collect_structures(str(project_root))
# ⚠ 단면유형을 넘겨야 성절토가 갈리고 표준경사 판정이 돈다. 안 넘기면 전 구조물이 # ⚠ 단면유형을 넘겨야 성절토가 갈리고 표준경사 판정이 돈다. 안 넘기면 전 구조물이
# 「가를 근거 없음」으로 떨어져 종전값 1:0.3 으로 선다(2026-09-09 실측). # 「가를 근거 없음」으로 떨어져 종전값 1:0.3 으로 선다(2026-09-09 실측).
return build_standard_sheets( return project_structure_sheets(str(project_root), section_modes)
build_unit_table(structures, names, section_modes), section_modes
)
except Exception: except Exception:
logger.exception("B07 표준도 장 목록 실패 — 빈 목록으로 둔다: %s", project_root) logger.exception("B07 표준도 장 목록 실패 — 빈 목록으로 둔다: %s", project_root)
return {"sheets": [], "structure_count": 0} return {"sheets": [], "structure_count": 0}
+1 -1
View File
@@ -31,7 +31,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
use_company_templates, use_company_templates,
use_title_fields, 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 ( from B07_DesignDetail.B07_DesignDetail_Router_Support import (
CROSS_STANDARD_ID, CROSS_STANDARD_ID,
LANDUSE_ID, LANDUSE_ID,
+2 -1
View File
@@ -50,10 +50,11 @@ import {
} from "./B07_DesignDetail_Api_Fetch"; } from "./B07_DesignDetail_Api_Fetch";
import { appendStructureEntities } from "./B07_DesignDetail_UI_Cad_Structures"; import { appendStructureEntities } from "./B07_DesignDetail_UI_Cad_Structures";
import { createFrameTemplateEditor } from "./B07_DesignDetail_UI_FrameEdit"; import { createFrameTemplateEditor } from "./B07_DesignDetail_UI_FrameEdit";
// 제원 칸은 B08 구조물도로 이관(2026-09-13, PLAN 3장) — 탭 배선 뒤 B07 표준도와 함께 뺌.
import { import {
buildStandardSpecPanel, buildStandardSpecPanel,
type StandardSpecResult, type StandardSpecResult,
} from "./B07_DesignDetail_UI_StandardSpec"; } from "../B08_Quantity/B08_Quantity_UI_StructureSheet_Spec";
/** CAD 앱 수량 패널로 넘기는 설계 컨텍스트 (openwebcad DesignMeta와 동일 형식). */ /** CAD 앱 수량 패널로 넘기는 설계 컨텍스트 (openwebcad DesignMeta와 동일 형식). */
interface DesignMeta { interface DesignMeta {
+1 -70
View File
@@ -337,73 +337,4 @@
font-size: var(--text-caption); font-size: var(--text-caption);
} }
/* 표준도 제원 입력 — 좌측 목록 아래 정보 칸에 선다. 장 하나가 곧 제원 조합 하나다. */ /* 표준도 제원 입력 모양은 B08 로 이관(2026-09-13) — `B08_Quantity_UI_StructureSheet_Spec.ts` 가 스스로 넣음. */
.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);
}
@@ -20,6 +20,10 @@
**단위당 ** 성분 수량을 `billing_quantity` 나눈 것이다. 실무 시트 머리의 `m당` · **단위당 ** 성분 수량을 `billing_quantity` 나눈 것이다. 실무 시트 머리의 `m당` ·
`개소당` · `` 단위이고, **구조물마다 다르다**(통일하지 않음 4-1). `개소당` · `` 단위이고, **구조물마다 다르다**(통일하지 않음 4-1).
**2026-09-13 B07 에서 이관**(PLAN 3 · 사용자 확정 구조물도는 B08 ).
자리 `B07_DesignDetail_Engine_Standard_Sheet.py`.
B07 그림 파일을 부르지 않게 기울기 판정 대상도 함께 옮김.
""" """
from __future__ import annotations from __future__ import annotations
@@ -27,6 +31,8 @@ from __future__ import annotations
import json import json
from typing import Any 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) return json.dumps(payload, ensure_ascii=False, sort_keys=True)
def _slope_or_none(structure: dict[str, Any]) -> float | None: #: **전면 기울기 판정** 대상 — 그림 대상(`FIGURE_TYPE_IDS`)과 **다르다**.
"""돌쌓기 계열이면 판정된 전면 기울기, 아니면 `None`. 판정은 그림 모듈이 가진 한 벌.""" #: 큰돌쌓기는 그림은 못 그려도 기울기는 판정된다(품셈 13-6 「1:0.3 이상」).
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardFigure import ( #: 한 목록으로 묶어 두었더니 큰돌쌓기를 그림에서 뺄 때 **장 제목의 「1:0.3」까지
SLOPE_TYPE_IDS, #: 사라졌다**(2026-09-09 화면 실측에서 잡음).
slope_of, 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: if str(structure.get("type_id") or "") not in SLOPE_TYPE_IDS:
return None return None
@@ -111,7 +144,8 @@ def sheet_title(structure: dict[str, Any]) -> str:
slope = _slope_or_none(structure) slope = _slope_or_none(structure)
if slope is not None: if slope is not None:
parts.append(f"1:{slope:g}") 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: if back:
parts.append(f"뒷길이 {int(back)}") parts.append(f"뒷길이 {int(back)}")
kind = options.get("stone_kind") kind = options.get("stone_kind")
@@ -137,6 +171,8 @@ def _rows_of(structure: dict[str, Any]) -> list[dict[str, Any]]:
{ {
"no": index, "no": index,
"name": component.get("name") or "", "name": component.get("name") or "",
# 실무 시트 머리의 「규격」 칸(210 · Ø50 등) — 전개가 이미 싣고 옴(2026-09-13).
"spec": component.get("spec") or "",
# 실무 시트의 「산출근거」 칸 — B08 이 이미 사람이 읽는 문구로 낸다. # 실무 시트의 「산출근거」 칸 — B08 이 이미 사람이 읽는 문구로 낸다.
"basis": component.get("basis") or "", "basis": component.get("basis") or "",
"unit_amount": _unit_amount(amount, quantity), "unit_amount": _unit_amount(amount, quantity),
@@ -16,6 +16,8 @@
**고치면 장이 갈릴 있다** 조합 전부에 같은 값을 넣으므로 장은 통째로 옮겨 가고 **고치면 장이 갈릴 있다** 조합 전부에 같은 값을 넣으므로 장은 통째로 옮겨 가고
쪼개지지 않는다. 개소만 다르게 하려면 구조물을 따로 고쳐야 하고, 그때 조합이 쪼개지지 않는다. 개소만 다르게 하려면 구조물을 따로 고쳐야 하고, 그때 조합이
되어 장이 하나 는다(PLAN 4-5b). 되어 장이 하나 는다(PLAN 4-5b).
**2026-09-13 B07 에서 이관**(PLAN 3). 자리 `B07_DesignDetail_Engine_Standard_Edit.py`.
""" """
from __future__ import annotations from __future__ import annotations
@@ -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"` ( 종류·조달·뒷길이· 표준도는 도면이자 **입력 화면**이다(PLAN 4-5b). `phase: "detail"` ( 종류·조달·뒷길이·
전면 기울기) 그리는 화면이 없어(2026-09-09 실측) 자리를 여기가 맡는다. 전면 기울기) 그리는 화면이 없어(2026-09-09 실측) 자리를 여기가 맡는다.
@@ -11,6 +14,7 @@
import asyncio import asyncio
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Any
from uuid import UUID from uuid import UUID
from fastapi import APIRouter 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 B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from common_util.common_util_storage import resolve_stored_project_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__) 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]: async def _project_root(project_id: UUID) -> str | None:
"""프로젝트에서 노선을 찾아 단면유형 표를 낸다 — 노선을 모르면 빈 표.""" """프로젝트 저장 폴더(절대경로). 못 찾으면 `None`."""
from B06_Section.B06_Section_Repository import get_workflow_route_context
from config.config_db import run_with_connection
try: try:
context = await run_with_connection(get_workflow_route_context, project_id) stored_path = await run_with_connection(get_project_storage_relative_path, project_id)
route_id = int((context or {}).get("route_id") or 0) return str(Path(resolve_stored_project_path(stored_path)).resolve())
except Exception: except Exception:
logger.exception("B07 노선 조회 실패: project_id=%s", project_id) logger.exception("B08 구조물도 — 저장 폴더 조회 실패: project_id=%s", project_id)
return {} return None
return await section_modes_of(route_id) if route_id else {}
async def section_modes_of(route_id: int) -> dict[float, str]: 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 실측). 값이 없는 것이 아니라 ** 넘긴 **이었다. 1:0.3 으로 선다**(2026-09-09 실측). 값이 없는 것이 아니라 ** 넘긴 **이었다.
표를 만드는 셈은 `section_modes_from_designs` 벌을 쓴다 부르는 쪽마다 다시 표를 만드는 셈은 `section_modes_from_designs` 벌을 쓴다 부르는 쪽마다 다시
짜면 B08 갈린다. 짜면 B08 갈린다.
과도기 노선 id 부르는 쪽은 B07 표준도 도면뿐임.
배선 B07 에서 표준도를 빼면 함께 지움.
""" """
from B06_Section.B06_Section_Repository import get_cross_section_designs from B06_Section.B06_Section_Repository import get_cross_section_designs
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import section_modes_from_designs from B08_Quantity.B08_Quantity_Engine_UnitQuantity import section_modes_from_designs
from config.config_db import run_with_connection
try: try:
designs = await run_with_connection(get_cross_section_designs, route_id) designs = await run_with_connection(get_cross_section_designs, route_id)
except Exception: except Exception:
logger.exception("B07 단면유형 조회 실패: route_id=%s", route_id) logger.exception("B08 구조물도 단면유형 조회 실패: route_id=%s", route_id)
return {} return {}
return section_modes_from_designs(designs) 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): class StandardSheetSpecRequest(BaseModel):
"""표준도 장 하나의 제원. **빈 값(null)은 「정한 적 없음」**이라 그 칸을 지운다.""" """표준도 장 하나의 제원. **빈 값(null)은 「정한 적 없음」**이라 그 칸을 지운다."""
@@ -78,8 +129,8 @@ class StandardSheetSpecRequest(BaseModel):
blinding_concrete: str | None = None blinding_concrete: str | None = None
@router.put("/{project_id}/standard-sheets/spec") @router.put("/{project_id}/quantity/structure-sheets/spec")
async def put_standard_sheet_spec( async def put_structure_sheet_spec(
project_id: UUID, payload: StandardSheetSpecRequest project_id: UUID, payload: StandardSheetSpecRequest
) -> JSONResponse: ) -> 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_Repository import load_structures, save_structures
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardSheet import standard_payload from B08_Quantity.B08_Quantity_Engine_StructureSheet_Edit import (
from B07_DesignDetail.B07_DesignDetail_Engine_Standard_Edit import (
apply_spec, apply_spec,
clean_spec, clean_spec,
drop_unregistered, drop_unregistered,
) )
try: project_root = await _project_root(project_id)
pool = get_db_pool() if project_root is None:
async with pool.acquire() as connection: return _not_found()
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": "프로젝트 저장 폴더를 찾지 못했습니다."},
)
modes = await section_modes_of_project(project_id) try:
payload_sheets = await asyncio.to_thread(standard_payload, project_root, modes) sheets = (await _sheets_of(project_id, project_root)).get("sheets") or []
sheets = payload_sheets.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) picked = next((s for s in sheets if s.get("key") == payload.sheet_key), None)
if picked is None: if picked is None:
return JSONResponse( return JSONResponse(
@@ -129,13 +173,13 @@ async def put_standard_sheet_spec(
spec, missing = drop_unregistered(type_id, spec, allowed) spec, missing = drop_unregistered(type_id, spec, allowed)
notes.extend(missing) notes.extend(missing)
try: 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) updated, changed = apply_spec(stored, member_ids, spec)
new_revision = await asyncio.to_thread( 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: except Exception as exc:
logger.exception("B07 표준도 제원 저장 실패: project_id=%s", project_id) logger.exception("B08 구조물도 제원 저장 실패: project_id=%s", project_id)
return JSONResponse( return JSONResponse(
status_code=409, status_code=409,
content={"status": "error", "message": f"제원을 저장하지 못했습니다 — {exc}"}, content={"status": "error", "message": f"제원을 저장하지 못했습니다 — {exc}"},
@@ -154,43 +198,26 @@ async def put_standard_sheet_spec(
) )
@router.get("/{project_id}/standard-sheets") @router.get("/{project_id}/quantity/structure-sheets")
async def get_standard_sheets(project_id: UUID) -> JSONResponse: async def get_structure_sheets(project_id: UUID) -> JSONResponse:
"""표준도(구조물도) **장 목록 + 하단표**. """구조물도(표준도) **장 목록 + 원단위 수량표**.
수량을 여기서 새로 셈하지 않는다 B08 원단위 전개를 그대로 받아 **제원 조합으로 묶고 수량을 여기서 새로 셈하지 않는다 B08 원단위 전개를 그대로 받아 **제원 조합으로 묶고
단위당으로 접기만** 한다(계산 자리는 , CLAUDE.md 5). 단위당으로 접기만** 한다(계산 자리는 , CLAUDE.md 5).
""" """
from B07_DesignDetail.B07_DesignDetail_Engine_Standard_Sheet import build_standard_sheets project_root = await _project_root(project_id)
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table if project_root is None:
from B08_Quantity.B08_Quantity_Router_Material import _collect_structures return _not_found()
try: try:
pool = get_db_pool() payload = await _sheets_of(project_id, project_root)
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())
except Exception: except Exception:
logger.exception("B07 표준도 조회 실패(경로): project_id=%s", project_id) logger.exception("B08 구조물도 전개 실패: 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)
return JSONResponse( return JSONResponse(
status_code=500, status_code=500,
content={"status": "error", "message": "구조물 원단위를 전개하지 못했습니다."}, content={"status": "error", "message": "구조물 원단위를 전개하지 못했습니다."},
) )
payload = build_standard_sheets(unit_table, modes)
payload["status"] = "success" payload["status"] = "success"
payload["project_id"] = str(project_id) payload["project_id"] = str(project_id)
# 왜 안 실렸는지 — 「구조물이 없다」와 「걸러졌다」를 화면이 가릴 수 있어야 한다.
payload["skipped_structures"] = skipped
return JSONResponse(content=payload) return JSONResponse(content=payload)
@@ -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<StructureSheetsResponse> {
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<K extends keyof HTMLElementTagNameMap>(
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<void> => {
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;
}
@@ -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"` ( ··· ) * (PLAN 4-5b) `phase: "detail"` ( ··· )
* (2026-09-09 실측: B06 b05 phase ). B06 * (2026-09-09 실측: B06 b05 phase ). B06
* · m(), . * · m(), .
@@ -53,16 +56,50 @@ const STONE_TYPES = new Set(["masonry_wet", "masonry_dry", "boulder_masonry"]);
/** 벽 두께 칸을 받는 종류 — **큰돌쌓기는 두께 식이 달라** 등록부에 칸이 없다(2026-09-09). */ /** 벽 두께 칸을 받는 종류 — **큰돌쌓기는 두께 식이 달라** 등록부에 칸이 없다(2026-09-09). */
const THICKNESS_TYPES = new Set(["masonry_wet", "masonry_dry"]); 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 { function field(label: string, control: HTMLElement, hint?: string): HTMLLabelElement {
const wrap = document.createElement("label"); const wrap = document.createElement("label");
wrap.className = "b07-spec__field"; wrap.className = "b08-spec__field";
const name = document.createElement("span"); const name = document.createElement("span");
name.className = "b07-spec__label"; name.className = "b08-spec__label";
name.textContent = label; name.textContent = label;
wrap.append(name, control); wrap.append(name, control);
if (hint) { if (hint) {
const help = document.createElement("small"); const help = document.createElement("small");
help.className = "b07-spec__hint"; help.className = "b08-spec__hint";
help.textContent = hint; help.textContent = hint;
wrap.append(help); wrap.append(help);
} }
@@ -75,7 +112,7 @@ function select(
autoLabel: string, autoLabel: string,
): HTMLSelectElement { ): HTMLSelectElement {
const el = document.createElement("select"); const el = document.createElement("select");
el.className = "b07-spec__input"; el.className = "b08-spec__input";
// 첫 보기가 **빈 값** — 「정한 적 없음」이 고를 수 있는 상태여야 한다. // 첫 보기가 **빈 값** — 「정한 적 없음」이 고를 수 있는 상태여야 한다.
const blank = document.createElement("option"); const blank = document.createElement("option");
blank.value = ""; blank.value = "";
@@ -100,20 +137,21 @@ export function buildStandardSpecPanel(
onSave: (result: StandardSpecResult) => Promise<string[]>, onSave: (result: StandardSpecResult) => Promise<string[]>,
initialNotes: string[] = [], initialNotes: string[] = [],
): HTMLDivElement { ): HTMLDivElement {
injectSpecStyles();
const panel = document.createElement("div"); const panel = document.createElement("div");
panel.className = "b07-spec ui-sidebar-section"; panel.className = "b08-spec ui-sidebar-section";
const title = document.createElement("h3"); const title = document.createElement("h3");
title.className = "b07-spec__title"; title.className = "b08-spec__title";
title.textContent = `제원 — ${sheet.title}`; title.textContent = `제원 — ${sheet.title}`;
const scope = document.createElement("p"); const scope = document.createElement("p");
scope.className = "b07-spec__scope"; scope.className = "b08-spec__scope";
scope.textContent = `이 장의 ${sheet.member_count}개소에 함께 걸립니다.`; scope.textContent = `이 장의 ${sheet.member_count}개소에 함께 걸립니다.`;
panel.append(title, scope); panel.append(title, scope);
if (!STONE_TYPES.has(sheet.type_id)) { if (!STONE_TYPES.has(sheet.type_id)) {
const none = document.createElement("p"); const none = document.createElement("p");
none.className = "b07-spec__scope"; none.className = "b08-spec__scope";
none.textContent = "이 종류는 표준도에서 받는 제원 칸이 아직 없습니다."; none.textContent = "이 종류는 표준도에서 받는 제원 칸이 아직 없습니다.";
panel.append(none); panel.append(none);
return panel; return panel;
@@ -129,7 +167,7 @@ export function buildStandardSpecPanel(
const blinding = select(BLINDINGS, options.blinding_concrete, "— 안 정함(넣음) —"); const blinding = select(BLINDINGS, options.blinding_concrete, "— 안 정함(넣음) —");
const slope = document.createElement("input"); const slope = document.createElement("input");
slope.className = "b07-spec__input"; slope.className = "b08-spec__input";
slope.type = "text"; slope.type = "text";
slope.inputMode = "decimal"; slope.inputMode = "decimal";
slope.placeholder = "비우면 자동"; slope.placeholder = "비우면 자동";
@@ -138,7 +176,7 @@ export function buildStandardSpecPanel(
// 벽 두께 — 비우면 실무 구조물도 식(상부 = 뒷길이 + 0.30 · 하부 = 상부 + 0.30×(H1)). // 벽 두께 — 비우면 실무 구조물도 식(상부 = 뒷길이 + 0.30 · 하부 = 상부 + 0.30×(H1)).
const thickness = (key: "thickness_top_m" | "thickness_bottom_m"): HTMLInputElement => { const thickness = (key: "thickness_top_m" | "thickness_bottom_m"): HTMLInputElement => {
const input = document.createElement("input"); const input = document.createElement("input");
input.className = "b07-spec__input"; input.className = "b08-spec__input";
input.type = "text"; input.type = "text";
input.inputMode = "decimal"; input.inputMode = "decimal";
input.placeholder = "비우면 식"; input.placeholder = "비우면 식";
@@ -170,7 +208,7 @@ export function buildStandardSpecPanel(
} }
const notes = document.createElement("ul"); const notes = document.createElement("ul");
notes.className = "b07-spec__notes"; notes.className = "b08-spec__notes";
// ⚠ 저장하면 표·그림을 다시 받으면서 **이 폼이 통째로 새로 그려진다** — 그때 안내가 // ⚠ 저장하면 표·그림을 다시 받으면서 **이 폼이 통째로 새로 그려진다** — 그때 안내가
// 지워지지 않게 밖에서 들고 있다가 다시 넣는다(2026-09-09 실화면에서 잡음). // 지워지지 않게 밖에서 들고 있다가 다시 넣는다(2026-09-09 실화면에서 잡음).
const showNotes = (messages: string[]): void => { const showNotes = (messages: string[]): void => {
@@ -187,7 +225,7 @@ export function buildStandardSpecPanel(
const save = document.createElement("button"); const save = document.createElement("button");
save.type = "button"; save.type = "button";
save.className = "b07-spec__save"; save.className = "b08-spec__save";
save.textContent = "제원 저장"; save.textContent = "제원 저장";
save.addEventListener("click", () => { save.addEventListener("click", () => {
void (async () => { void (async () => {
@@ -301,6 +301,19 @@ STmate 코드는 `B00001`·`M00042` 꼴로 **층 글자 + 다섯 자리**이고
(`JDCODE_M.BSC` 자재 57,580건 등)는 여전히 암호화라 못 읽음. (`JDCODE_M.BSC` 자재 57,580건 등)는 여전히 암호화라 못 읽음.
남의 내부 번호를 정본으로 삼으면 나중에 빈 자리를 못 메움 남의 내부 번호를 정본으로 삼으면 나중에 빈 자리를 못 메움
### ⚠⚠ 규격은 선택이 아니라 **조인 키**임 — 수치 근거 (29번)
암호화 기준자료도 **스키마는 평문**이라 뼈대를 잴 수 있었음. 잰 결과:
```
자재 사전 JDCODE_M.BSC 코드 57,580건 ↔ 명칭 1,761종
같은 이름이 규격만 달리해 평균 33건씩 갈림
규격 칸 110자 · 단위 사전 59종(자재)·130종(통합) · 분류 4~5단
```
**이름만으로는 원리적으로 못 맞춤.** 「이름으로 맞추고 규격으로 확인」이 아니라
**(이름 + 규격) 한 쌍이 키**임. 못 맞춘 497줄의 뿌리가 이 수치로 설명됨.
### 쓰는 법 ### 쓰는 법
1. `size` 칸을 **반드시 같이 볼 것** — 3장 ③ 「규격이 없으면 매칭 성공이 아님」이 여기서 걸림 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. 고칠 때 ## 10. 고칠 때
이 문서는 **브레인이 관리함.** 코드 창이 계약을 바꿔야 하면 **고치지 말고 브레인에게 알릴 것** 이 문서는 **브레인이 관리함.** 코드 창이 계약을 바꿔야 하면 **고치지 말고 브레인에게 알릴 것**
@@ -35,6 +35,8 @@
| 26 편집기와 사이클식 | **`Cm` 54꼴·품셈 인자·식 편집기·낙찰율** — 계약/실행/기성 단계 발견 | (저장소 파일) | | 26 편집기와 사이클식 | **`Cm` 54꼴·품셈 인자·식 편집기·낙찰율** — 계약/실행/기성 단계 발견 | (저장소 파일) |
| 27 계약·실행·기성 단계 | **설계 뒤 네 단계** — 낙찰율 3갈래, 기성은 계약제잡비율, 실행 차액보정 | (저장소 파일) | | 27 계약·실행·기성 단계 | **설계 뒤 네 단계** — 낙찰율 3갈래, 기성은 계약제잡비율, 실행 차액보정 | (저장소 파일) |
| 28 변환·수량산출·식언어 | **식 변수의 대상/저장 비목** · 수량산출서 셀별명 · 조달청 변환 · 직종 106종 | (저장소 파일) | | 28 변환·수량산출·식언어 | **식 변수의 대상/저장 비목** · 수량산출서 셀별명 · 조달청 변환 · 직종 106종 | (저장소 파일) |
| 29 암호화자료의 뼈대 | **값 없이 구조만** — 스키마 평문, 카탈로그 규모, 분류 4~5단, 단위 59~130종 | (저장소 파일) |
| 30 조달청 호환규정 XSD | ⭐ **국가 표준 납품 규격 전문** — 문서 뼈대·제경비 적용산식·기계경비 산출 | (저장소 파일) |
> **정본은 저장소 파일이다**`resources/knowledge/original/경쟁사 프로그램/STmate 분석/20_분석/`. > **정본은 저장소 파일이다**`resources/knowledge/original/경쟁사 프로그램/STmate 분석/20_분석/`.
> 위 Orca 링크는 보기용이며, 문서를 지식DB 자리로 옮기면서 주소가 한 번 바뀌었다. > 위 Orca 링크는 보기용이며, 문서를 지식DB 자리로 옮기면서 주소가 한 번 바뀌었다.
@@ -124,6 +126,17 @@
| 조달청 호환 | 몰랐음 | **`Conv_Jodal_ST8`·`Conv_ST8_XML` 양방향** | 28번 §3 | | 조달청 호환 | 몰랐음 | **`Conv_Jodal_ST8`·`Conv_ST8_XML` 양방향** | 28번 §3 |
| 노임 직종 | 코드만 | **직종 사전 106종** 평문 | 28번 §3.1 | | 노임 직종 | 코드만 | **직종 사전 106종** 평문 | 28번 §3.1 |
| 암호화 파일 | 2개 | ⚠ **3개**`EQSYNC.DBF` 추가 | 28번 §5 | | 암호화 파일 | 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 분석일: 2026-09-13
대상: `C:\경영정보(주)` 대상: `C:\경영정보(주)`
@@ -85,7 +85,7 @@ ST_Conv_Mode1/2/3 · ST_Conv_Data · ST_Copy_Data
**납품 호환 경로가 프로그램 안에 있다.** `호환규정코드``wM_Mrg_BSC`의 매칭 키 1순위였던 것(20번 §2)과 이어진다. **납품 호환 경로가 프로그램 안에 있다.** `호환규정코드``wM_Mrg_BSC`의 매칭 키 1순위였던 것(20번 §2)과 이어진다.
XML **스키마는 문자열로 안 남는다** — 실행 중에 조립한다. 태그 이름을 정적으로는 못 얻는다. > ⛔ **이 판정은 틀렸다. 30번에서 정정했다.** 스키마는 `System\Valid_Xml.vsl`**XSD 파일로 있다**(1,429줄, 조달청 공사원가호환규정 V1.0.5).
### 3.1 노임 직종 사전 106종 ### 3.1 노임 직종 사전 106종
@@ -163,7 +163,7 @@ EQSYNC.DBF 54행 × 3열 (2.9 KB)
| 무엇 | 상태 | | 무엇 | 상태 |
|---|---| |---|---|
| XML 스키마 태그 | 실행 중 조립 — 정적으로 못 얻음 | | ~~XML 스키마 태그~~ | ✅ **해결**`Valid_Xml.vsl` XSD 전문 (30번) |
| `EQSYNC` 내용 | 암호화 | | `EQSYNC` 내용 | 암호화 |
| 수량산출서 파일 형식 | 표본 0건 (`QCODE` 전부 빈 표) | | 수량산출서 파일 형식 | 표본 0건 (`QCODE` 전부 빈 표) |
| 조달청 변환 결과물 | 표본 0건 | | 조달청 변환 결과물 | 표본 0건 |
@@ -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`).
@@ -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
<xsd:documentation>
이 문서는 공사원가호환규정 V1.0.4 XML스키마이다. 최종수정일 2022-10-06
… 1.0.5 건축공사의 사업내용 정보 추가 (2022-11-28)
</xsd:documentation>
```
**조달청 공사원가통합관리시스템(`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 자료에 없다 — 품셈에서 가져와야 한다.
@@ -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개: 해당 있음, 해당 없음
@@ -0,0 +1,3 @@
호환규정코드 2026년 9월 1일 조달청 발표분 반영
조달청 공사원가통합관리시스템 https://npccs.g2b.go.kr 발표
@@ -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("<IHH",b,4)
f=[];o=32
while o<len(b) and b[o]!=0x0D:
f.append((b[o:o+11].rstrip(b"\0").decode("cp949","replace"), chr(b[o+11]), b[o+16], b[o+17])); o+=32
return nrec,hlen,rlen,f
print("### 암호화 기준자료 — 스키마는 평문인가, 레코드는 어디까지 막혔나\n")
for p in sorted(R.rglob("*")):
if not p.is_file() or p.suffix.upper() not in (".BSC",".CSTA"): continue
b=p.read_bytes()
try: nrec,hlen,rlen,f = schema(b)
except Exception: continue
if not f: continue
print("%-42s %6d× %2d%7.1f KB" % (p.name[:42], nrec, len(f), len(b)/1024))
print(" 필드: %s" % ", ".join("%s(%s%d%s)"%(n,t,l,("."+str(d)) if d else "") for n,t,l,d in f))
# 필드별 평문 비율 — 앞 200 레코드
off=[1]
for n,t,l,d in f: off.append(off[-1]+l)
ratio=[]
N=min(nrec,200)
for i,(n,t,l,d) in enumerate(f):
pr=tot=0
for k in range(N):
seg=b[hlen+k*rlen+off[i] : hlen+k*rlen+off[i]+l]
for c in seg:
tot+=1
if 32<=c<127 or c>=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()
@@ -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("<IHH",b,4)
f=[];o=32
while b[o]!=0x0D: f.append((b[o:o+11].rstrip(b"\0").decode("cp949","replace"),b[o+16]));o+=32
off=[1]
for nm,l in f: off.append(off[-1]+l)
rows=[]
for k in range(nrec):
base=hlen+k*rlen
rows.append({nm: b[base+off[i]:base+off[i]+f[i][1]] for i,(nm,l) in enumerate(f)})
return [x[0] for x in f], rows
def key(v): return hashlib.blake2s(v, digest_size=5).hexdigest()
print("### ① 같은 값 → 같은 암호문 인가 (동일성 검사)")
names,rows=table(next(R.rglob("JDCODE_M.BSC")))
for fl in ("TYP_1","TYP_2","TYP_","EQCODE","GNCODE","UNIT"):
d=collections.Counter(key(r[fl]) for r in rows)
print(" %-8s 서로 다른 암호문 %6d개 / %d행 최빈 %d" % (fl, len(d), len(rows), d.most_common(1)[0][1]))
print("\n### ② 분류 트리 복원 — GRPCOD_Z (값은 몰라도 모양은 나옴)")
gn,gr=table(next(R.rglob("GRPCOD_Z.BSC")))
lev=collections.Counter(bytes(r["LEV"]).strip() for r in gr)
print(" LEV 서로 다른 값 %d개 (암호문 기준): %s" % (len(lev), dict(list(lev.most_common(6)))))
cur=collections.Counter(key(r["CUR_COD"]) for r in gr)
rot=collections.Counter(key(r["ROT_COD"]) for r in gr)
print(" CUR_COD 고유 %d / %d행 ROT_COD 고유 %d" % (len(cur), len(gr), len(rot)))
# 부모-자식: ROT_COD 가 다른 행의 CUR_COD 와 같은가
curset={key(r["CUR_COD"]) for r in gr}
linked=sum(1 for r in gr if key(r["ROT_COD"]) in curset)
print(" ROT_COD 가 다른 행의 CUR_COD 와 일치: %d / %d 행 (%.1f%%)" % (linked, len(gr), 100*linked/len(gr)))
ch=collections.Counter(key(r["ROT_COD"]) for r in gr)
print(" 자식 수 분포 상위: %s" % [c for _,c in ch.most_common(8)])
print("\n### ③ 자재 57,580건의 군집 — GNCODE(그룹코드) 로 묶기")
names,jm=table(next(R.rglob("JDCODE_M.BSC")))
g=collections.Counter(key(r["GNCODE"]) for r in jm)
print(" 서로 다른 그룹 %d개, 전체 %d" % (len(g), len(jm)))
sz=[c for _,c in g.most_common()]
print(" 군집 크기 상위 12: %s" % sz[:12])
print(" 1건짜리 군집 %d개, 10건 이상 군집 %d" % (sum(1 for c in sz if c==1), sum(1 for c in sz if c>=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)])
@@ -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]))
@@ -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*<xsd:simpleType>\s*<xsd:restriction[^>]*>((?:\s*<xsd:enumeration value="[^"]*"/>)+)', 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]))
@@ -67,6 +67,8 @@
| 26 | **편집기와 사이클식**`Cm` 54꼴, 품셈 인자, 식 편집기, 낙찰율 | | 26 | **편집기와 사이클식**`Cm` 54꼴, 품셈 인자, 식 편집기, 낙찰율 |
| 27 | **계약·실행·기성 단계** — 설계 뒤 네 단계의 계산 구조 | | 27 | **계약·실행·기성 단계** — 설계 뒤 네 단계의 계산 구조 |
| 28 | **변환·수량산출·식언어** — 식 변수 대상/저장, 조달청 변환, 직종 106종 | | 28 | **변환·수량산출·식언어** — 식 변수 대상/저장, 조달청 변환, 직종 106종 |
| 29 | **암호화자료의 뼈대** — 값 없이 구조만, 카탈로그 규모와 분류 깊이 |
| 30 | ⭐ **조달청 호환규정 XSD** — 국가 표준 납품 규격 전문 |
## 확정된 굵직한 것 ## 확정된 굵직한 것
+1 -1
View File
@@ -12,7 +12,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT)) 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: def _stone(**options) -> dict:
@@ -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 from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardSheet import ( # noqa: E402
build_standard_drawing, 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: def _sheet(type_id: str, **options) -> dict:
@@ -18,7 +18,7 @@ ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path: if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT)) 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, build_standard_sheets,
) )
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402 from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402
@@ -24,7 +24,7 @@ if str(ROOT) not in sys.path:
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardFigure import ( # noqa: E402 from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardFigure import ( # noqa: E402
build_figure, 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, BLINDING_CHOICES,
EDITABLE_KEYS, EDITABLE_KEYS,
clean_spec, clean_spec,
@@ -110,7 +110,7 @@ def test_여기서_고치기_단추가_고정_액션_줄에_있다() -> None:
assert "specJumpButton.hidden = false" in page assert "specJumpButton.hidden = false" in page
assert "specJumpButton.hidden = true" in page assert "specJumpButton.hidden = true" in page
# 폼에 버림 칸이 섰다 — 끄는 자리가 화면에 있다(확정 8-2). # 폼에 버림 칸이 섰다 — 끄는 자리가 화면에 있다(확정 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" encoding="utf-8"
) )
assert "blinding_concrete" in form assert "blinding_concrete" in form
@@ -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