chore: 네 창 작업 269건 main 반영 (2026-09-09) (#13)

This commit was merged in pull request #13.
This commit is contained in:
2026-09-09 18:21:26 +09:00
981 changed files with 79528 additions and 1041 deletions
+7 -1
View File
@@ -33,7 +33,10 @@ dist/
# 저장소 및 백업 데이터
storage/
0_old/
docs/
# docs/ 는 git 추적 대상 (위키·완료 이력·검증 기록 — 다른 AI 가 위키화)
# 아래 둘만 제외 — 창끼리 시놀로지로 공유하는 장부
/PLAN.md
/OWNERS.md
graphify-out/
# 리소스 (현재 통째 제외 — knowledge 예외 전환은 사용자 결정 대기)
@@ -60,3 +63,6 @@ tmp/
config/corridor_node/
# 횡단 서버 재계산 번들 — `npm run build:server-calc` 산출물(2026-09-06).
config/server_calc_node/
# graphify 날짜별 산출물 — `graphify update` 가 다시 만듦 (한 벌 796KB × 날마다)
docs/wiki/graphify-out/20*/
+31 -1
View File
@@ -51,9 +51,33 @@ export interface UploadStatusResponse {
completed_chunk_indexes: number[];
}
/**
* 「지우고 진행할까?」를 물어야 하는 상태(409). 서버가 무엇이 지워지는지 함께 준다.
* 되돌릴 수 없는 삭제라 사람이 한 번 보고 정한다(2026-09-08 사용자 지시).
*/
export class ReplaceOutputsConfirmRequired extends Error {
readonly targets: string[];
constructor(message: string, targets: string[]) {
super(message);
this.name = "ReplaceOutputsConfirmRequired";
this.targets = targets;
}
}
async function readJsonOrThrow<T>(response: Response): Promise<T> {
const payload = (await response.json()) as T & { message?: string };
const payload = (await response.json()) as T & {
message?: string;
confirm?: string;
targets?: string[];
};
if (!response.ok) {
if (response.status === 409 && payload.confirm === "replace_outputs") {
throw new ReplaceOutputsConfirmRequired(
payload.message ?? "이미 만들어 둔 결과가 지워집니다.",
payload.targets ?? [],
);
}
throw new Error(payload.message ?? `HTTP ${response.status}`);
}
return payload;
@@ -62,9 +86,11 @@ async function readJsonOrThrow<T>(response: Response): Promise<T> {
export async function uploadProjectFiles(
projectId: string,
files: readonly File[],
confirmReplace = false,
): Promise<FileUploadResponse> {
const formData = new FormData();
for (const file of files) formData.append("files", file, file.name);
if (confirmReplace) formData.append("confirm_replace", "true");
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
@@ -88,6 +114,7 @@ export async function createUploadSession(
fingerprint?: string | null,
completeUpload = false,
lasFree = false,
confirmReplace = false,
): Promise<ChunkSessionCreateResponse> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-sessions`, {
method: "POST",
@@ -100,6 +127,7 @@ export async function createUploadSession(
fingerprint: fingerprint ?? null,
complete_upload: completeUpload,
las_free: lasFree,
confirm_replace: confirmReplace,
}),
});
return await readJsonOrThrow<ChunkSessionCreateResponse>(response);
@@ -131,6 +159,7 @@ export async function finalizeUploadSession(
completeUpload: boolean,
fingerprint?: string | null,
lasFree = false,
confirmReplace = false,
): Promise<FileUploadResponse> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/finalize`, {
method: "POST",
@@ -142,6 +171,7 @@ export async function finalizeUploadSession(
complete_upload: completeUpload,
fingerprint: fingerprint ?? null,
las_free: lasFree,
confirm_replace: confirmReplace,
}),
});
return await readJsonOrThrow<FileUploadResponse>(response);
+28 -1
View File
@@ -22,6 +22,7 @@ from B03_FileInput.B03_FileInput_Repository import (
get_project_storage_relative_path,
list_incomplete_upload_sessions,
list_project_input_files,
supersede_previous_input_files,
)
# 분리 전 이 파일에 있던 이름은 그대로 다시 내보낸다 — 옛 이름을 참조하는
@@ -58,6 +59,10 @@ from B03_FileInput.B03_FileInput_Router_Helpers import (
from B03_FileInput.B03_FileInput_Router_Helpers import (
_SHAPEFILE_REQUIRED_TYPES as _SHAPEFILE_REQUIRED_TYPES,
)
from B03_FileInput.B03_FileInput_Router_Helpers import (
OutputsWouldBeDiscarded,
_confirm_replace_response,
)
from B03_FileInput.B03_FileInput_Router_Helpers import (
_already_uploaded as _already_uploaded,
)
@@ -112,6 +117,7 @@ from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_workflow import load_project_workflow
from common_util.common_util_workflow_state import (
get_workflow_state,
is_analysis_running,
)
from config.config_db import get_db_pool
from config.config_system import (
@@ -130,6 +136,7 @@ async def upload_project_files(
project_id: UUID,
files: list[UploadFile] = File(...),
las_free: bool = Form(False),
confirm_replace: bool = Form(False),
session: dict[str, Any] = Depends(verify_session),
) -> FileUploadResponse | JSONResponse:
"""프로젝트 입력 파일을 저장·분석하고 DB 메타데이터를 기록한다."""
@@ -199,6 +206,14 @@ async def upload_project_files(
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))
# 분석이 도는 중이면 새 자료를 받지 않는다 — 청크·임시배치 경로와 같은 가드다
# (2026-09-08). 이 갈래만 빠져 있어 분석 2개가 같은 산출물 경로에서 부딪혔다.
async with connection.cursor(aiomysql.DictCursor) as cursor:
if await is_analysis_running(cursor, str(project_id)):
return JSONResponse(
status_code=409,
content={"status": "error", "message": _ANALYSIS_RUNNING_MESSAGE},
)
await connection.begin()
try:
@@ -229,6 +244,14 @@ async def upload_project_files(
crs_epsg=int(crs_epsg) if crs_epsg is not None else None,
metadata=metadata,
)
# 같은 이름의 옛 행은 내려 둔다 — 청크 경로와 같은 처리다(2026-09-08).
# 이 갈래만 빠져 있어 같은 파일이 두 줄로 활성으로 남았다.
await supersede_previous_input_files(
connection,
project_id,
descriptor.original_filename,
input_file_id,
)
results.append(
UploadedFileResult(
input_file_id=input_file_id,
@@ -240,7 +263,7 @@ async def upload_project_files(
)
)
point_cloud_input_id = await _complete_file_input_if_ready(
connection, project_id, las_free
connection, project_id, las_free, confirm_replace
)
await connection.commit()
except Exception:
@@ -264,6 +287,10 @@ async def upload_project_files(
task_name=f"b04-preprocess-auto-{project_id}",
)
return FileUploadResponse(project_id=str(project_id), files=results)
except OutputsWouldBeDiscarded as exc:
for saved_path in saved_paths:
saved_path.unlink(missing_ok=True)
return _confirm_replace_response(exc)
except LookupError as exc:
return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id)
except (OSError, ValueError) as exc:
@@ -35,8 +35,10 @@ from B03_FileInput.B03_FileInput_Repository import (
from B03_FileInput.B03_FileInput_Router_Errors import lookup_error_response
from B03_FileInput.B03_FileInput_Router_Helpers import (
_ANALYSIS_RUNNING_MESSAGE,
OutputsWouldBeDiscarded,
_already_uploaded,
_complete_file_input_if_ready,
_confirm_replace_response,
_schedule_background_task,
_total_chunks,
_write_stage_metadata,
@@ -110,6 +112,7 @@ async def create_project_upload_session(
connection,
project_id,
payload.las_free,
payload.confirm_replace,
)
await connection.commit()
except Exception:
@@ -144,6 +147,8 @@ async def create_project_upload_session(
chunk_size_bytes=chunk_size_bytes,
total_chunks=total_chunks,
)
except OutputsWouldBeDiscarded as exc:
return _confirm_replace_response(exc)
except LookupError as exc:
return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id)
except (OSError, ValueError) as exc:
@@ -202,6 +207,8 @@ async def upload_project_chunk(
total_chunks=int(upload_session["total_chunks"]),
chunk_hash=chunk_hash,
)
except OutputsWouldBeDiscarded as exc:
return _confirm_replace_response(exc)
except LookupError as exc:
return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id)
except (OSError, ValueError) as exc:
@@ -300,6 +307,7 @@ async def finalize_project_upload(
connection,
project_id,
payload.las_free,
payload.confirm_replace,
)
await connection.commit()
except Exception:
@@ -329,6 +337,8 @@ async def finalize_project_upload(
task_name=f"b04-preprocess-auto-{project_id}",
)
return FileUploadResponse(project_id=str(project_id), files=[result])
except OutputsWouldBeDiscarded as exc:
return _confirm_replace_response(exc)
except LookupError as exc:
return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id)
except (OSError, ValueError) as exc:
@@ -373,6 +383,8 @@ async def get_project_upload_status(
completed_chunks=len(completed_indexes),
completed_chunk_indexes=completed_indexes,
)
except OutputsWouldBeDiscarded as exc:
return _confirm_replace_response(exc)
except LookupError as exc:
return lookup_error_response(exc, logger, context="B03 업로드", project_id=project_id)
except (OSError, ValueError) as exc:
@@ -12,6 +12,7 @@ from typing import Any
from uuid import UUID
import aiomysql
from fastapi.responses import JSONResponse
from B03_FileInput.B03_FileInput_Email import (
send_file_upload_complete_email,
@@ -40,6 +41,61 @@ logger = logging.getLogger(__name__)
_ANALYSIS_RUNNING_MESSAGE = "이 프로젝트는 지금 분석 중입니다. 끝난 뒤에 새 자료를 올려 주세요."
# 새 자료를 받으면 옛 산출물을 지운다 — 지워질 것이 있으면 **먼저 묻는다**(2026-09-08).
# 창 넷이 한 프로젝트를 함께 볼 수 있어, 말없이 지우면 남이 며칠 만든 설계가 사라진다.
# 되돌릴 길이 없다(초기값 스냅숏까지 함께 버린다).
_OUTPUT_STAGE_LABELS: tuple[tuple[str, str], ...] = (
("B04_PreProcess", "지표면·전처리 결과"),
("B05_Profile", "노선·종단 설계"),
("B06_Section", "횡단 설계"),
("B07_DesignDetail", "도면"),
("B08_Quantity", "수량 산출"),
("B09_Estimation", "원가·내역"),
)
class OutputsWouldBeDiscarded(Exception):
"""이미 있는 설계 산출물을 지워야 새 자료를 받을 수 있는 상태.
라우터가 409 로 돌려주고, 사람이 「지우고 진행」을 고르면 `confirm_replace=True`
로 다시 들어온다. 자동 절차는 이 예외를 만들지 않는다 — 부를 때 확인을 켜 준다.
"""
def __init__(self, targets: list[str]) -> None:
self.targets = targets
super().__init__("설계 산출물이 있어 확인이 필요합니다.")
def _confirm_replace_response(exc: "OutputsWouldBeDiscarded") -> JSONResponse:
"""「지우고 진행할까?」를 사람에게 묻는 409 — 무엇이 지워지는지 함께 알린다."""
return JSONResponse(
status_code=409,
content={
"status": "confirm_required",
"confirm": "replace_outputs",
"message": (
"이 프로젝트에는 이미 만들어 둔 결과가 있습니다. 새 자료로 갈면 "
"아래가 지워지고 **되돌릴 수 없습니다**: " + " · ".join(exc.targets)
),
"targets": exc.targets,
},
)
def describe_existing_outputs(project_root: Path) -> list[str]:
"""새 자료를 받으면 **지워질 것**의 이름을 사람 말로 늘어놓는다. 없으면 빈 목록."""
targets: list[str] = []
for stage, label in _OUTPUT_STAGE_LABELS:
stage_root = project_root / stage
if not stage_root.is_dir():
continue
if any(entry.is_file() for entry in stage_root.rglob("*")):
targets.append(label)
if (project_root / "initial_snapshot").is_dir():
targets.append("초기값 스냅숏(되돌리기의 기준)")
return targets
_REQUIRED_FILE_TYPES = frozenset({"prj", "tfw"})
_POINT_CLOUD_FILE_TYPES = frozenset({"las", "laz"})
# 계획노선은 CSV 또는 shapefile 중 하나면 된다 (2026-08-31 — 원청 정식 노선이 shapefile).
@@ -149,7 +205,13 @@ async def _complete_file_input_if_ready(
connection: aiomysql.Connection,
project_id: UUID,
las_free: bool = False,
confirm_replace: bool = True,
) -> int:
"""새 자료로 갈아 끼운다. `confirm_replace=False` 면 지울 것이 있을 때 멈추고 묻는다.
기본이 `True` 인 까닭 — 자동 절차(체인·스크립트)는 물음에 걸리면 안 된다.
사람이 올리는 갈래(라우터)만 `False` 로 불러 확인을 받는다(2026-09-08 사용자 지시).
"""
file_types, point_cloud_input_id, route_csv_input_id = await get_project_input_readiness(
connection, project_id
)
@@ -172,6 +234,11 @@ async def _complete_file_input_if_ready(
gap_message = merge_gap_error(terrain_paths)
if gap_message:
raise ValueError(gap_message)
# ⚠ 여기서부터는 되돌릴 수 없다 — 아래 셋이 파일까지 지운다. 지울 것이 있으면 먼저 묻는다.
if not confirm_replace:
targets = describe_existing_outputs(project_root)
if targets:
raise OutputsWouldBeDiscarded(targets)
clear_designing(project_root)
discard_initial_snapshot(project_root)
await purge_project_outputs(connection, str(project_id), project_root)
@@ -86,6 +86,11 @@ from B03_FileInput.B03_FileInput_Schema_Temp import (
)
from B03_FileInput.B03_FileInput_Service_WF1 import trigger_wf1_analysis_and_email
from common_util.common_util_auth import verify_session
from B03_FileInput.B03_FileInput_Router_Helpers import (
OutputsWouldBeDiscarded,
_confirm_replace_response,
describe_existing_outputs,
)
from common_util.common_util_project_reset import purge_project_outputs
from common_util.common_util_storage import (
resolve_stored_project_path,
@@ -359,6 +364,7 @@ async def upload_batch_files(
async def attach_temp_batch(
project_id: UUID,
batch_id: str,
confirm_replace: bool = False,
session: dict[str, Any] = Depends(verify_session),
) -> TempBatchAttachResponse | JSONResponse:
"""보관함 자료를 프로젝트 영구저장소로 옮기고 초기 분석을 시작한다.
@@ -403,6 +409,11 @@ async def attach_temp_batch(
# 자료가 갈리므로 옛 계산 결과(파일 + DB)를 먼저 지운다 — 남겨 두면 아직 다시
# 만들어지지 않은 뒷단계 화면이 옛 결과를 새 자료 것인 양 보여준다.
# ⚠ 되돌릴 수 없다 — 지울 것이 있으면 먼저 묻는다(2026-09-08).
if not confirm_replace:
targets = describe_existing_outputs(project_root)
if targets:
return _confirm_replace_response(OutputsWouldBeDiscarded(targets))
async with pool.acquire() as connection:
await connection.begin()
try:
+6
View File
@@ -62,6 +62,9 @@ class ChunkSessionCreateRequest(FileUploadDescriptor):
# 파일 지문 — 같은 이름으로 **같은 내용**이 다시 올라오는지 전송 전에 가린다.
# 화면이 파일 크기 + 앞·중간·끝 조각으로 만든다([[fileFingerprint]]).
fingerprint: str | None = Field(default=None, max_length=128)
# 이미 있는 설계 산출물을 지워도 좋다는 사람의 대답(2026-09-08). 거짓이면 지울 것이
# 있을 때 409 로 멈추고 무엇이 지워지는지 알린다 — 되돌릴 수 없는 삭제라서다.
confirm_replace: bool = False
class ChunkSessionCreateResponse(BaseModel):
@@ -103,6 +106,9 @@ class UploadFinalizeRequest(BaseModel):
# 세션 생성 때 쓴 지문을 그대로 다시 받아 입력 파일에 남긴다. 다음에 같은 파일이
# 올라오면 이 값으로 전송을 건너뛴다(upload_sessions에 컬럼을 더하지 않으려는 선택).
fingerprint: str | None = Field(default=None, max_length=128)
# 이미 있는 설계 산출물을 지워도 좋다는 사람의 대답(2026-09-08). 거짓이면 지울 것이
# 있을 때 409 로 멈추고 무엇이 지워지는지 알린다 — 되돌릴 수 없는 삭제라서다.
confirm_replace: bool = False
class UploadStatusResponse(BaseModel):
+28 -4
View File
@@ -14,6 +14,13 @@
display: flex;
flex-direction: column;
gap: var(--spacing-32);
/* ⚠ flex 자식은 기본이 `min-width: auto` 라 **내용보다 안 줄어든다** — 그래서 안쪽
격자가 넘치면 페이지 몸통이 통째로 가로로 밀렸다(2026-09-08). 여기서 끊는다. */
min-width: 0;
}
.b03-file__main-layout > * {
min-width: 0;
}
.b03-file__control-panel {
@@ -52,7 +59,8 @@
칸을 먼저 나누므로 내용과 무관하게 정확히 1:1:1 이 된다. 좁아지면 칸이 접힌다. */
.b03-file__pick-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
/* ⚠ 좁은 폭에서는 200px 도 넘친다 — `min(200px, 100%)` 로 컨테이너까지만 줄인다. */
grid-template-columns: repeat(auto-fit, minmax(min(200px, 100%), 1fr));
align-items: stretch;
gap: var(--spacing-8);
}
@@ -213,10 +221,15 @@
}
/* 계획노선 | 지형(LAS) — 컨테이너를 둘로 나눈다(2026-08-31 사용자 지시).
노선은 카드 5장(shapefile 한 벌), 지형은 4장이라 폭을 5:4 비슷하게 준다. */
노선은 카드 5장(shapefile 한 벌), 지형은 4장이라 폭을 5:4 비슷하게 준다.
⚠ **좁아지면 한 줄로 접는다**(2026-09-08 사용자 지적) — 2열을 고정하면 좁은 폭에서
각 열이 카드 하나도 못 담아 내용이 컨테이너 밖으로 넘치고 글자가 안 읽혔다.
`auto-fit` + `min(360px, 100%)` 이라, 폭이 모자라면 칸 수가 스스로 1로 줄고
**최소폭이 컨테이너를 넘지 않는다**(고정 minmax 는 그 아래에서 넘친다). */
.b03-file__columns {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(auto-fit, minmax(min(360px, 100%), 1fr));
gap: var(--spacing-16);
align-items: start;
}
@@ -353,9 +366,12 @@
/* 카드가 좁아지면 한 줄에 제목·상태·[선택]이 못 들어가 제목이 글자 단위로 접힌다
(2026-09-03 실측: 안내 패널을 연 상태에서 카드 폭 210px). 폭이 모자라면 열을 줄인다. */
/* ⚠ `minmax(260px, …)` 는 **칸이 260px 보다 좁아질 수 없다**는 뜻이라, 컨테이너가
그보다 좁아지면 카드가 밖으로 넘친다(2026-09-08 좁은 폭 실측). `min(260px, 100%)`
으로 두면 좁을 때 **컨테이너 폭까지 줄어들어** 넘치지 않는다. */
.b03-file__group-content {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr));
gap: var(--spacing-12);
}
@@ -425,11 +441,19 @@
글자 단위로 접히므로, 자리가 모자라면 배지·삭제 버튼이 다음 줄로 내려가게 한다. */
/* 제목·상태·[선택]이 **한 줄**에 선다(2026-09-03) — 줄바꿈을 허용하면 [선택]이 아래로
내려가 카드가 두 줄이 됐다. 자리가 모자라면 제목이 줄어든다(min-width: 0). */
/* ⚠ 좁은 폭에서 배지·[선택]·[×] 가 밀려 나가지 않게 **줄 자체가 넘치지 않도록** 막는다
(2026-09-08). 제목은 이미 말줄임이라, 넘치는 것은 늘 오른쪽 붙박이들이었다. */
.b03-file__card-header {
display: flex;
flex-wrap: nowrap;
gap: var(--spacing-8);
align-items: center;
min-width: 0;
max-width: 100%;
}
.b03-file__card-header > * {
min-width: 0;
}
.b03-file__card-icon {
+46 -14
View File
@@ -15,6 +15,7 @@ import {
checkWF1AnalysisStatus,
createUploadSession,
finalizeUploadSession,
ReplaceOutputsConfirmRequired,
uploadFileChunk,
type UploadedFileResult,
} from "./B03_FileInput_Api_Fetch";
@@ -77,6 +78,31 @@ export function confirmReplaceUpload(slotLabel: string, fileName: string): Promi
* 파일 1건을 청크로 올린다. 중단된 세션이 있으면 그 지점부터 이어 올린다.
* 진행 상황은 `onProgress`로만 알린다 — 갱신 주기는 config 값으로 제한한다.
*/
/**
* 「이미 만들어 둔 결과가 지워집니다」를 사람에게 한 번 묻는다.
* 되돌릴 수 없는 삭제라 확인 없이는 진행하지 않는다(2026-09-08 사용자 지시).
* 자동 절차는 이 자리를 지나지 않는다 — 서버가 확인을 켜고 부른다.
*/
async function askReplaceOutputs<T>(run: (confirmReplace: boolean) => Promise<T>): Promise<T> {
try {
return await run(false);
} catch (error) {
if (!(error instanceof ReplaceOutputsConfirmRequired)) throw error;
const detail = error.targets.length
? `
지워지는 것: ${error.targets.join(" · ")}`
: "";
const agreed = window.confirm(
`${error.message}${detail}
되돌릴 수 없습니다. 계속할까요?`,
);
if (!agreed) throw new Error("업로드를 취소했습니다 — 기존 결과는 그대로 있습니다.");
return await run(true);
}
}
export async function uploadOneFile(
projectId: string,
state: FileSlotState,
@@ -97,13 +123,16 @@ export async function uploadOneFile(
const fingerprint = state.uploadSessionId ? null : await fileFingerprint(file);
let session = state.uploadSessionId;
if (!session) {
const created = await createUploadSession(
projectId,
file,
chunkSizeBytes,
fingerprint,
completeUpload,
lasFree,
const created = await askReplaceOutputs((confirmReplace) =>
createUploadSession(
projectId,
file,
chunkSizeBytes,
fingerprint,
completeUpload,
lasFree,
confirmReplace,
),
);
if (created.already_uploaded) {
state.progressBytes = file.size;
@@ -155,13 +184,16 @@ export async function uploadOneFile(
}
}
const response = await finalizeUploadSession(
projectId,
session,
totalChunks,
completeUpload,
fingerprint,
lasFree,
const response = await askReplaceOutputs((confirmReplace) =>
finalizeUploadSession(
projectId,
session,
totalChunks,
completeUpload,
fingerprint,
lasFree,
confirmReplace,
),
);
localStorage.removeItem(storageKey);
saveB03UploadedFile(projectId, {
+46
View File
@@ -154,6 +154,7 @@ async def sync_uphill_overrides_into_designs(
rock_boundary_offset_m=design.get("rock_boundary_offset_m"),
two_stage_slope=bool(design.get("two_stage_slope", True)),
ditch_enabled=design.get("ditch_enabled"),
ditch_choice=design.get("ditch_choice"),
surface_drop_m=ford_drop_at(float(chainage), ford_drops),
**curve_widening_args(cross_record),
)
@@ -199,6 +200,50 @@ def _merge_irregular_into_longitudinal(
atomic_write_json(path, data)
#: 지표 샘플링 조건을 적어 두는 파일 — **나중에 측점을 더 만들 때 같은 조건으로** 뜨기 위해.
#: ⚠ 조건이 다르면 그 측점만 다른 지표에서 뽑혀 옆 측점과 지반고가 어긋난다.
SAMPLING_SNAPSHOT_NAME = "sampling.json"
def sampling_snapshot_path(project_root: Path) -> Path:
return project_root / "B06_Section" / SAMPLING_SNAPSHOT_NAME
def save_sampling_snapshot(project_root: Path, request: RouteConfirmRequest) -> None:
"""확정 때 쓴 지표 샘플링 조건을 남긴다(2026-09-09).
왜 — 관을 나중에 놓으면 그 측점이 안 생기는데(계획서 3-14), 나중에 만들려면 **그때와 같은
조건**으로 떠야 한다. 조건을 안 남기면 되짚을 길이 없어 **지어내야 하는 자리**가 된다.
"""
if not request.filter_key or not request.method:
return
path = sampling_snapshot_path(project_root)
path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(
path,
{
"filter_key": request.filter_key,
"method": request.method,
"smooth": bool(request.smooth),
"surface_model_id": request.surface_model_id,
},
)
def load_sampling_snapshot(project_root: Path) -> dict[str, Any] | None:
"""남겨 둔 샘플링 조건. 없으면 `None` — **지어내지 않는다**(노선 확정을 한 번 더 받는다)."""
path = sampling_snapshot_path(project_root)
if not path.is_file():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
if not data.get("filter_key") or not data.get("method"):
return None
return data
async def _append_irregular_cross_sections(
connection: aiomysql.Connection,
project_id: UUID,
@@ -208,6 +253,7 @@ async def _append_irregular_cross_sections(
"""확정 시 비정규 측점의 횡단을 생성해 종단 파일에 병합한다(파일 기반, 비치명적 호출용)."""
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path))
save_sampling_snapshot(project_root, request)
stored_options = await get_latest_section_options(connection, project_id)
crs_epsg = await get_surface_crs_epsg(connection, project_id, request.surface_model_id)
irregular_stations = await asyncio.to_thread(
+677 -16
View File
@@ -1,6 +1,6 @@
{
"schema_version": 1,
"comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 docs/raw/PLAN.md 구조물 리스트와 resources/knowledge/technical_info/01_임도 참조. 2026-08-17 선택지 재편: B군(종단배수)·F군(생태/녹화)·G군(노면공)은 enabled:false 보존 — B05 선택지에서 빠지고 B06 개별 횡단도 옵션으로 재사용한다. 바닥막이(bed_sill)·생태연못(eco_pond)은 임도 미사용 확정으로 삭제. 배관·BOX암거·물넘이·세월교는 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않으며, 배관 부속(유형/집수정/기슭막이/돌붙임)·날개벽 옵션 정의는 B06/B07 필수 승격의 선언 근거다. ★기본값 원칙(2026-08-16 크로스체크 2차): default를 두는 것은 ①법정이 등급과 무관하게 못박은 단일 수치 ②사용자가 개발 단계에서 확정한 값 ③도메인 수치가 아닌 표시용 문자열, 이 셋뿐이다. 재료·형식 같은 설계자 판단 선택지와 미확정 제원은 default 없이 required로 두어 사용자가 직접 고르게 한다 (pipe_diameter_mm 1000 = 별표2 원칙값+사용자 확정 — 화이트리스트 등재). BOX암거 본체 2.0×2.0·날개벽 유입/유출 있음·짧은쪽 높이 1m·길이 2m·각도 45°는 2026-08-17 사용자 확정값이다 — 본체 규격은 실무 관측 규격(2.0×2.0·3.0×3.0)을 폼에서 프리셋 select로 받고 필요 시 사용자 지정으로 자유 입력하되, 저장 키는 body_width_m·body_height_m 숫자 그대로다(프리셋 전용 키를 만들지 않는다). 이 값들은 B05 서브폼이 실제로 받으므로 phase를 detail로 두지 않는다. ★보호공 개편(2026-08-17 사용자 지시): 구 `*_pitching`(있음/없음) + `*_pitching_finish`(찰/메) 두 축을 `*_protection` 한 축(돌붙임(찰)·돌붙임(메)·도수로)으로 합쳤다 — 도수로·산비탈수로(B4)는 유출부에서 돌붙임 대신 쓰는 공종이라 별도 구조물이 아니라 보호공 선택지로 받는다. \"없음\"은 삭제(물이 흐르는 자리라 보호공 필수). 치수는 배타 — 돌붙임이면 `*_protection_area_m2`(10㎡), 도수로면 `*_protection_width_m`(1m, 지식DB 수치 근거 없음·사용자 확정, 실무 확인 후 개선). 구 저장분은 `common_util_drainage_pipes.parse_pipe_points`가 새 키로 옮긴다.",
"comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 PLAN.md 구조물 리스트와 resources/knowledge/technical_info/01_임도 참조. 2026-08-17 선택지 재편: B군(종단배수)·F군(생태/녹화)·G군(노면공)은 enabled:false 보존 — B05 선택지에서 빠지고 B06 개별 횡단도 옵션으로 재사용한다. 바닥막이(bed_sill)·생태연못(eco_pond)은 임도 미사용 확정으로 삭제. 배관·BOX암거·물넘이·세월교는 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않으며, 배관 부속(유형/집수정/기슭막이/돌붙임)·날개벽 옵션 정의는 B06/B07 필수 승격의 선언 근거다. ★기본값 원칙(2026-08-16 크로스체크 2차): default를 두는 것은 ①법정이 등급과 무관하게 못박은 단일 수치 ②사용자가 개발 단계에서 확정한 값 ③도메인 수치가 아닌 표시용 문자열, 이 셋뿐이다. 재료·형식 같은 설계자 판단 선택지와 미확정 제원은 default 없이 required로 두어 사용자가 직접 고르게 한다 (pipe_diameter_mm 1000 = 별표2 원칙값+사용자 확정 — 화이트리스트 등재). BOX암거 본체 2.0×2.0·날개벽 유입/유출 있음·짧은쪽 높이 1m·길이 2m·각도 45°는 2026-08-17 사용자 확정값이다 — 본체 규격은 실무 관측 규격(2.0×2.0·3.0×3.0)을 폼에서 프리셋 select로 받고 필요 시 사용자 지정으로 자유 입력하되, 저장 키는 body_width_m·body_height_m 숫자 그대로다(프리셋 전용 키를 만들지 않는다). 이 값들은 B05 서브폼이 실제로 받으므로 phase를 detail로 두지 않는다. ★보호공 개편(2026-08-17 사용자 지시): 구 `*_pitching`(있음/없음) + `*_pitching_finish`(찰/메) 두 축을 `*_protection` 한 축(돌붙임(찰)·돌붙임(메)·도수로)으로 합쳤다 — 도수로·산비탈수로(B4)는 유출부에서 돌붙임 대신 쓰는 공종이라 별도 구조물이 아니라 보호공 선택지로 받는다. \"없음\"은 삭제(물이 흐르는 자리라 보호공 필수). 치수는 배타 — 돌붙임이면 `*_protection_area_m2`(10㎡), 도수로면 `*_protection_width_m`(1m, 지식DB 수치 근거 없음·사용자 확정, 실무 확인 후 개선). 구 저장분은 `common_util_drainage_pipes.parse_pipe_points`가 새 키로 옮긴다.",
"types": [
{
"type_id": "pipe",
@@ -179,6 +179,15 @@
"default": 2.5,
"required": false,
"phase": "detail"
},
{
"key": "revet_foundation",
"label": "기슭막이 기초",
"input": "select",
"choices": ["기초유", "기초버림"],
"default": null,
"required": false,
"phase": "detail"
}
]
},
@@ -202,6 +211,15 @@
"default": 2.0,
"required": false
},
{
"key": "revet_foundation",
"label": "기슭막이 기초",
"input": "select",
"choices": ["기초유", "기초버림"],
"default": null,
"required": false,
"phase": "detail"
},
{
"key": "body_height_m",
"label": "본체 높이",
@@ -441,7 +459,18 @@
"abbr": "개거"
},
"drawing_views": ["plan", "profile", "cross_section", "quantity"],
"options": []
"options": [
{
"key": "ditch_spec",
"label": "규격",
"input": "select",
"choices": ["콘크리트 개거 150×200", "L형수로 H=0.2"],
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 실무 붙박이(콘크리트 개거)로 돎"
}
]
},
{
"type_id": "ditch_side",
@@ -691,7 +720,7 @@
"key": "form",
"label": "형식",
"input": "select",
"choices": ["중력식", "반중력식", "캔틸레버식", "부벽식"],
"choices": ["중력식", "반중력식", "캔틸레버식", "부벽식", "식생옹벽블럭"],
"default": null,
"required": true,
"phase": "detail"
@@ -701,7 +730,28 @@
"label": "설치 측",
"input": "select",
"choices": ["자동(성토 쪽)", "좌", "우"],
"default": "자동(성토 쪽)"
"default": "자동(성토 쪽)",
"default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식"
},
{
"key": "weep_hole_diameter_mm",
"label": "물빼기 구멍 지름",
"input": "number",
"unit": "㎜",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
},
{
"key": "weep_hole_area_m2",
"label": "물빼기 구멍 1개당 벽면적",
"input": "number",
"unit": "㎡",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
}
]
},
@@ -770,12 +820,80 @@
"required": true,
"phase": "detail"
},
{
"key": "stone_supply",
"label": "조달",
"input": "select",
"choices": ["채집", "구입"],
"default": "채집",
"required": false,
"phase": "detail"
},
{
"key": "stone_coeff_basis",
"label": "야면석 계수",
"input": "select",
"choices": ["품셈", "실무 관행"],
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀"
},
{
"key": "fill_concrete_mpa",
"label": "채움 강도",
"input": "select",
"choices": ["180", "210"],
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 계산 쪽 기준 강도로 돎"
},
{
"key": "face_slope_ratio",
"label": "전면 기울기 (1:n 의 n)",
"input": "number",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
},
{
"key": "foundation",
"label": "기초",
"input": "select",
"choices": ["기초유", "기초버림"],
"default": null,
"required": true,
"phase": "detail"
},
{
"key": "side",
"label": "설치 측",
"input": "select",
"choices": ["자동(성토 쪽)", "좌", "우"],
"default": "자동(성토 쪽)"
"default": "자동(성토 쪽)",
"default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식"
},
{
"key": "weep_hole_diameter_mm",
"label": "물빼기 구멍 지름",
"input": "number",
"unit": "㎜",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
},
{
"key": "weep_hole_area_m2",
"label": "물빼기 구멍 1개당 벽면적",
"input": "number",
"unit": "㎡",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
}
]
},
@@ -844,12 +962,80 @@
"required": true,
"phase": "detail"
},
{
"key": "stone_supply",
"label": "조달",
"input": "select",
"choices": ["채집", "구입"],
"default": "채집",
"required": false,
"phase": "detail"
},
{
"key": "stone_coeff_basis",
"label": "야면석 계수",
"input": "select",
"choices": ["품셈", "실무 관행"],
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀"
},
{
"key": "fill_concrete_mpa",
"label": "채움 강도",
"input": "select",
"choices": ["180", "210"],
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 계산 쪽 기준 강도로 돎"
},
{
"key": "face_slope_ratio",
"label": "전면 기울기 (1:n 의 n)",
"input": "number",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
},
{
"key": "foundation",
"label": "기초",
"input": "select",
"choices": ["기초유", "기초버림"],
"default": null,
"required": true,
"phase": "detail"
},
{
"key": "side",
"label": "설치 측",
"input": "select",
"choices": ["자동(성토 쪽)", "좌", "우"],
"default": "자동(성토 쪽)"
"default": "자동(성토 쪽)",
"default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식"
},
{
"key": "weep_hole_diameter_mm",
"label": "물빼기 구멍 지름",
"input": "number",
"unit": "㎜",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
},
{
"key": "weep_hole_area_m2",
"label": "물빼기 구멍 1개당 벽면적",
"input": "number",
"unit": "㎡",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
}
]
},
@@ -904,7 +1090,16 @@
"key": "form",
"label": "형식",
"input": "select",
"choices": ["콘크리트", "돌(찰)", "돌(메)", "블록쌓기", "돌망태", "흙포대", "통나무쌓기"],
"choices": [
"콘크리트",
"돌(찰)",
"돌(메)",
"블록쌓기",
"돌망태",
"흙포대",
"통나무쌓기",
"떼"
],
"default": null,
"required": true,
"phase": "detail"
@@ -914,7 +1109,126 @@
"label": "설치 측",
"input": "select",
"choices": ["자동(성토 쪽)", "좌", "우"],
"default": "자동(성토 쪽)"
"default": "자동(성토 쪽)",
"default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식"
},
{
"key": "tiers",
"label": "단 수(다단)",
"input": "number",
"unit": "단",
"default": 1,
"required": false,
"phase": "b05",
"default_basis": "1 = 다단 없음 — 도메인 수치가 아니라 「단이 하나」라는 표식(기슭막이와 같은 벌)"
},
{
"key": "lift_m",
"label": "기준 올림(사면 위로)",
"input": "number",
"unit": "m",
"default": 0,
"required": false,
"phase": "b05",
"default_basis": "0 = 자동 자리 그대로 — 올리지 않음을 뜻함(기슭막이와 같은 벌)"
},
{
"key": "shift_m",
"label": "기준 좌우 이동",
"input": "number",
"unit": "m",
"default": 0,
"required": false,
"phase": "b05",
"default_basis": "0 = 자동 자리 그대로 — 옮기지 않음을 뜻함(기슭막이와 같은 벌)"
},
{
"key": "back_len_cm",
"label": "뒷길이",
"input": "select",
"choices": ["25", "30", "35", "45", "55", "60", "75"],
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 돌쌓기 기본값으로 서고 「기본값으로 섰음」이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검"
},
{
"key": "stone_kind",
"label": "돌 종류",
"input": "select",
"choices": ["야면석·호박돌", "깬잡석", "깬돌", "견치돌"],
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 야면석 계수 열로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검"
},
{
"key": "stone_supply",
"label": "조달",
"input": "select",
"choices": ["채집", "구입"],
"default": "채집",
"required": false,
"phase": "detail",
"default_basis": "「채집」 — 별표2 「야면석 등은 가급적 현장에서 채취·사용」 권고. 구조물마다 바꿀 수 있음"
},
{
"key": "stone_coeff_basis",
"label": "야면석 계수",
"input": "select",
"choices": ["품셈", "실무 관행"],
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀"
},
{
"key": "fill_concrete_mpa",
"label": "채움 강도",
"input": "select",
"choices": ["180", "210"],
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 계산 쪽 기준 강도로 돎"
},
{
"key": "face_slope_ratio",
"label": "전면 기울기 (1:n 의 n)",
"input": "number",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
},
{
"key": "foundation",
"label": "기초",
"input": "select",
"choices": ["기초유", "기초버림"],
"default": null,
"required": true,
"phase": "detail"
},
{
"key": "weep_hole_diameter_mm",
"label": "물빼기 구멍 지름",
"input": "number",
"unit": "㎜",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
},
{
"key": "weep_hole_area_m2",
"label": "물빼기 구멍 1개당 벽면적",
"input": "number",
"unit": "㎡",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
}
]
},
@@ -983,12 +1297,80 @@
"required": true,
"phase": "detail"
},
{
"key": "stone_supply",
"label": "조달",
"input": "select",
"choices": ["채집", "구입"],
"default": "채집",
"required": false,
"phase": "detail"
},
{
"key": "stone_coeff_basis",
"label": "야면석 계수",
"input": "select",
"choices": ["품셈", "실무 관행"],
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀"
},
{
"key": "fill_concrete_mpa",
"label": "채움 강도",
"input": "select",
"choices": ["180", "210"],
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 계산 쪽 기준 강도로 돎"
},
{
"key": "face_slope_ratio",
"label": "전면 기울기 (1:n 의 n)",
"input": "number",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
},
{
"key": "foundation",
"label": "기초",
"input": "select",
"choices": ["기초유", "기초버림"],
"default": null,
"required": true,
"phase": "detail"
},
{
"key": "side",
"label": "설치 측",
"input": "select",
"choices": ["자동(성토 쪽)", "좌", "우"],
"default": "자동(성토 쪽)"
"default": "자동(성토 쪽)",
"default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식"
},
{
"key": "weep_hole_diameter_mm",
"label": "물빼기 구멍 지름",
"input": "number",
"unit": "㎜",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
},
{
"key": "weep_hole_area_m2",
"label": "물빼기 구멍 1개당 벽면적",
"input": "number",
"unit": "㎡",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
}
]
},
@@ -1079,6 +1461,98 @@
"required": true,
"phase": "detail"
},
{
"key": "spillway",
"label": "방수로",
"input": "select",
"choices": ["있음", "없음"],
"default": null,
"required": true,
"phase": "detail"
},
{
"key": "stone_kind",
"label": "돌 종류",
"input": "select",
"choices": ["야면석·호박돌", "깬잡석", "깬돌", "견치돌"],
"default": null,
"required": true,
"phase": "detail"
},
{
"key": "back_len_cm",
"label": "뒷길이 3",
"input": "select",
"choices": ["25", "30", "35", "45", "55", "60", "75"],
"default": null,
"required": true,
"phase": "detail"
},
{
"key": "stone_supply",
"label": "조달",
"input": "select",
"choices": ["채집", "구입"],
"default": "채집",
"required": false,
"phase": "detail"
},
{
"key": "stone_coeff_basis",
"label": "야면석 계수",
"input": "select",
"choices": ["품셈", "실무 관행"],
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀"
},
{
"key": "fill_concrete_mpa",
"label": "채움 강도",
"input": "select",
"choices": ["180", "210"],
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 계산 쪽 기준 강도로 돎"
},
{
"key": "face_slope_ratio",
"label": "전면 기울기 (1:n 의 n)",
"input": "number",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
},
{
"key": "foundation",
"label": "기초",
"input": "select",
"choices": ["기초유", "기초버림"],
"default": null,
"required": true,
"phase": "detail"
},
{
"key": "top_length_m",
"label": "상장 ⓐ (윗변)",
"input": "number",
"unit": "m",
"default": null,
"required": true,
"phase": "detail"
},
{
"key": "bottom_length_m",
"label": "하장 ⓑ (아랫변)",
"input": "number",
"unit": "m",
"default": null,
"required": true,
"phase": "detail"
},
{
"key": "length_m",
"label": "길이",
@@ -1092,6 +1566,26 @@
"input": "number",
"unit": "m",
"default": 2.0
},
{
"key": "weep_hole_diameter_mm",
"label": "물빼기 구멍 지름",
"input": "number",
"unit": "㎜",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
},
{
"key": "weep_hole_area_m2",
"label": "물빼기 구멍 1개당 벽면적",
"input": "number",
"unit": "㎡",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
}
]
},
@@ -1115,6 +1609,53 @@
"required": false,
"phase": "b05"
},
{
"key": "stone_supply",
"label": "조달",
"input": "select",
"choices": ["채집", "구입"],
"default": "채집",
"required": false,
"phase": "detail"
},
{
"key": "stone_coeff_basis",
"label": "야면석 계수",
"input": "select",
"choices": ["품셈", "실무 관행"],
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀"
},
{
"key": "fill_concrete_mpa",
"label": "채움 강도",
"input": "select",
"choices": ["180", "210"],
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 계산 쪽 기준 강도로 돎"
},
{
"key": "face_slope_ratio",
"label": "전면 기울기 (1:n 의 n)",
"input": "number",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
},
{
"key": "foundation",
"label": "기초",
"input": "select",
"choices": ["기초유", "기초버림"],
"default": null,
"required": true,
"phase": "detail"
},
{
"key": "area_m2",
"label": "면적",
@@ -1176,7 +1717,8 @@
"choices": ["양쪽", "좌", "우"],
"default": "양쪽",
"required": false,
"phase": "b05"
"phase": "b05",
"default_basis": "「좌」는 셀렉트 첫 항목일 뿐 도메인 확정값이 아님 — 측점마다 사용자가 고름"
},
{
"key": "tiers",
@@ -1185,7 +1727,8 @@
"unit": "단",
"default": 1,
"required": false,
"phase": "b05"
"phase": "b05",
"default_basis": "1 = 다단 없음 — 도메인 수치가 아니라 「단이 하나」라는 표식"
},
{
"key": "lift_m",
@@ -1194,7 +1737,8 @@
"unit": "m",
"default": 0,
"required": false,
"phase": "b05"
"phase": "b05",
"default_basis": "0 = 자동 자리 그대로"
},
{
"key": "shift_m",
@@ -1203,7 +1747,8 @@
"unit": "m",
"default": 0,
"required": false,
"phase": "b05"
"phase": "b05",
"default_basis": "0 = 자동 자리 그대로"
},
{
"key": "form",
@@ -1214,6 +1759,73 @@
"required": false,
"phase": "b05"
},
{
"key": "back_len_cm",
"label": "뒷길이",
"input": "select",
"choices": ["25", "30", "35", "45", "55", "60", "75"],
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 돌쌓기 기본값으로 서고 「기본값으로 섰음」이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검"
},
{
"key": "stone_kind",
"label": "돌 종류",
"input": "select",
"choices": ["야면석·호박돌", "깬잡석", "깬돌", "견치돌"],
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 야면석 계수 열로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검"
},
{
"key": "stone_supply",
"label": "조달",
"input": "select",
"choices": ["채집", "구입"],
"default": "채집",
"required": false,
"phase": "detail"
},
{
"key": "stone_coeff_basis",
"label": "야면석 계수",
"input": "select",
"choices": ["품셈", "실무 관행"],
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀"
},
{
"key": "fill_concrete_mpa",
"label": "채움 강도",
"input": "select",
"choices": ["180", "210"],
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 계산 쪽 기준 강도로 돎"
},
{
"key": "face_slope_ratio",
"label": "전면 기울기 (1:n 의 n)",
"input": "number",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
},
{
"key": "foundation",
"label": "기초",
"input": "select",
"choices": ["기초유", "기초버림"],
"default": null,
"required": true,
"phase": "detail"
},
{
"key": "height_m",
"label": "높이",
@@ -1249,6 +1861,26 @@
"default": 5,
"required": false,
"phase": "b05"
},
{
"key": "weep_hole_diameter_mm",
"label": "물빼기 구멍 지름",
"input": "number",
"unit": "㎜",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
},
{
"key": "weep_hole_area_m2",
"label": "물빼기 구멍 1개당 벽면적",
"input": "number",
"unit": "㎡",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
}
]
},
@@ -1411,14 +2043,43 @@
{
"type_id": "spoil_bank",
"group": "E",
"name": "사토장",
"placement": "site",
"name": "유용토운반작업장(구 사토장)",
"placement": "interval",
"style": {
"color": "#8c9a2e",
"abbr": "사토장"
},
"drawing_views": ["plan", "quantity"],
"drawing_views": ["plan", "cross_section", "quantity"],
"options": [
{
"key": "side",
"label": "쌓는 쪽",
"input": "select",
"choices": ["자동(성토 쪽)", "좌", "우"],
"default": "자동(성토 쪽)",
"required": false,
"phase": "b05",
"default_basis": "「자동(성토 쪽)」 — 흙은 성토 쪽에 쌓는다는 지금까지의 동작을 이어받는 표식. 측점마다 좌·우로 바꿈"
},
{
"key": "fill_slope_ratio",
"label": "비탈 기울기 (1:n 의 n)",
"input": "number",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 그 측점의 노선 성토 기울기를 그대로 씀 — 지식DB 가 「기본 비탈기울기는 근거에 없다」고 못 박은 자리"
},
{
"key": "extra_distance_m",
"label": "추가 운반거리",
"input": "number",
"unit": "m",
"default": 0,
"required": false,
"phase": "detail",
"default_basis": "0 = 「추가 없음」 — 노선 안 사토장은 측점 누가거리로 거리가 나오므로 이 칸은 외부 사토장 대비 여벌"
},
{
"key": "area_m2",
"label": "면적",
@@ -48,6 +48,18 @@ class StructureOptionField(BaseModel):
# 기본값이 그대로 저장된다(2026-09-07 사용자: 「폼 선택은 가능하게 반영하고 나중에
# 선택 비활성화로 하자」). 칸 자체를 없애면 나중에 켤 자리를 다시 찾아야 한다.
enabled: bool = True
# ── 「기본값도 필수도 아닌」 세 번째 갈래 (2026-09-09 신설) ────────────────────
# 지금까지 칸은 둘 중 하나여야 했다 — **기본값이 있거나, 필수이거나**. 그러지 않으면
# 빈 값이 조용히 저장되기 때문이다. 그런데 실제로는 셋째가 있다:
# **「비워 두는 것이 뜻인 칸」** — 비면 계산 쪽이 **기준값**으로 돌고 그 사실이 화면에
# 사유로 뜬다. 값을 넣으면 그 값이 이긴다(예: 전면 기울기, 물빼기 구멍, 뒷길이).
# 여기에 기준의 출처를 **한 줄로 적어** 두면, 그 칸이 왜 비어 있어도 되는지가
# **정본 파일 안에** 남는다. 시험 예외 목록(창마다 따로 사는 파일)로 두면 창이 갈릴 때
# 같은 시험이 다른 창에서 깨진다 — 실제로 그렇게 깨졌다(2026-09-09).
empty_means: str | None = None
# 기본값이 **도메인 확정값이 아닐 때** 그 뜻을 적는다(예: 「다단 없음」·「안 더함」).
# 법정·확정 수치면 비워 둔다 — 비어 있는 것이 「확정값」이라는 뜻이다.
default_basis: str | None = None
class StructureType(BaseModel):
@@ -354,6 +354,22 @@ export function createStructuresSection(
read: () => (option.input === "number" ? Number(input.value) || 0 : input.value),
isEmpty: () => input.value.trim() === "",
});
// 전면 기울기는 품셈 표준경사 표(0.20~0.50) 밖 값도 받는다 — 실무 도면이 S0.7·S0.8 을
// 쓴다(2026-09-09 실무 DWG 확인). **막지 않고 알리기만** 한다: 막으면 실물이 안 들어간다.
if (option.key === "face_slope_ratio") {
const warn = (): void => {
const value = Number(input.value);
const outside =
input.value.trim() !== "" && Number.isFinite(value) && (value < 0.2 || value > 0.5);
input.title = outside
? "품셈 표준경사 표 범위(0.20~0.50) 밖 값입니다 — 근거를 적어 두시기 바랍니다."
: "비워 두면 품셈 표대로 자동 판정합니다.";
input.classList.toggle("is-outside-standard", outside);
};
warn();
input.addEventListener("input", warn);
input.addEventListener("change", warn);
}
// 길이·기준측점 전/후가 바뀌면 서로 연동하고 측점 범위 표시가 즉시 따라온다
// (범위 계산 타입, 2026-08-19). 전·후는 **나중에 고친 쪽**이 살아남고 반대쪽이
// 길이 − 그 값으로 맞춰진다. 길이를 고치면 나중에 고쳤던 쪽을 지키며 재배분.
@@ -30,6 +30,7 @@
}
.b05-drainage.is-collapsed .b05-drainage__header,
.b05-drainage.is-collapsed .b05-drainage__summary,
.b05-drainage.is-collapsed .b05-drainage__viewport {
display: none;
}
@@ -419,3 +419,9 @@
.b05-structure-section .b05-structure__position-row .b05-route__field {
gap: 6px;
}
/* 전면 기울기가 품셈 표준경사 표(0.20~0.50) 밖일 때 — 실무가 S0.7 을 쓰므로 **막지 않고**
테두리로만 알린다(2026-09-09). 말풍선에 까닭이 들어 있다. */
.is-outside-standard {
border-color: var(--color-warning, #c98a00);
}
+27 -1
View File
@@ -233,6 +233,9 @@ export interface CulvertSet {
side?: string;
/** 독립 기슭막이 다단 요청 수(1이면 단일 벽). */
tiers?: number;
/** 기초 축 — "기초유" | "기초버림"(실무 정본 탭 제목). **비어 있으면 터파기를 안 그린다**
* — 고르기 전에는 근거가 없다(2026-09-09 사용자 확정 ④). */
foundation?: string | null;
}
/** 세월교 날개벽 한쪽 — 횡단면엔 안 보이고 바닥판 연장량만 넘긴다. */
@@ -423,6 +426,8 @@ export interface CrossDesign {
| { type: "none" };
/** 측구 생성 여부(엔진이 자동/override 반영해 실제 적용한 결과). */
ditch_enabled?: boolean;
/** 사용자가 정한 선택(`null` = 자동). 결과와 다른 값이다. */
ditch_choice?: boolean | null;
/** 포장 중첩 여부와 포장층 두께(포장 시). */
paved: boolean;
pavement_thickness_m?: number;
@@ -450,6 +455,25 @@ export interface CrossDesign {
/** 암반부에 적용할 지반유형(`ripping_rock`/`blasting_rock`). 토사 측점은 null. */
cut_rock_kind?: GroundType | null;
fill_area_m2: number;
/**
* 사토장(유용토운반작업장) 몫 — **`fill_area_m2` 와 합치지 않는다**(2026-09-09 확정 ㉠).
* 노면 끝 바깥은 노선 성토가 아니라 사토장 성토라, 받는 쪽이 갈라 볼 수 있어야 한다.
* 사토장이 없는 측점은 전부 0·null 이다(구 데이터에는 아예 없어 optional).
*/
spoil_fill_area_m2?: number;
spoil_fill_side?: "left" | "right" | null;
spoil_fill_width_m?: number;
spoil_fill_max_width_m?: number;
spoil_fill_line?: Array<{ offset_m: number; elevation_m: number }>;
spoil_fill_unclosed?: boolean;
/** 사토장이 대신 차지해 노선 성토에서 뺀 몫(㎡) — 되짚기용. 합계에 또 넣지 말 것. */
spoil_fill_replaced_fill_m2?: number;
/** 구간(구조물) 단위 값 — 측점마다 같은 값이 실린다. 말풍선·수량이 되짚는 데 쓴다. */
spoil_fill_capacity_m3?: number;
spoil_fill_placed_m3?: number;
spoil_fill_unplaced_m3?: number;
spoil_fill_structure_id?: string | null;
spoil_fill_extra_distance_m?: number | null;
/** 성토측 자연 지반 경사(rise/run). 자연방토 판정 입력. 성토측이 없으면 null. */
fill_ground_slope?: number | null;
/**
@@ -495,8 +519,10 @@ export interface CrossDesignRequest {
two_stage_slope?: boolean;
/** 이 측점만 쓰는 암 절토 경사비(1:n 의 n). 없으면 표준값(2026-09-07). */
cut_slope_ratio?: number | null;
/** 측구 생성 여부. null/미지정=자동 판정, true/false=수동 override. */
/** 옛 이름 — 결과값이 그대로 실려 오던 자리(호환). 새 요청은 `ditch_choice` 를 쓴다. */
ditch_enabled?: boolean | null;
/** 측구를 둘지 **사용자가 정한 선택**. `null`/미지정 = 자동 판정(2026-09-09 정리). */
ditch_choice?: boolean | null;
/** 설정 패널 편집값. 요청값 → config 기본값 순으로 우선한다. */
standard_cross_section?: StandardCrossSection;
}
+33 -1
View File
@@ -89,7 +89,23 @@ export const USER_TOUCHED_KEYS = [
] as const;
/** 다시 계산해도 살려 두는 값 — 위 사용자 값에 **상태를 나르는 둘**을 더한 것. */
const PRESERVED_KEYS = ["status", "pavement_suggested", ...USER_TOUCHED_KEYS] as const;
// 사토장 구간값 — **브라우저가 못 만드는 값**이라 이어 붙인다. 구간 전체를 봐야 나오는
// 값이고(용량 배분) 정본은 [저장] 때 서버가 다시 낸다. 안 이으면 계획선을 만지는 순간
// 말풍선에서 「구간 용량 …」이 사라진다.
const SPOIL_KEYS = [
"spoil_fill_capacity_m3",
"spoil_fill_placed_m3",
"spoil_fill_unplaced_m3",
"spoil_fill_structure_id",
"spoil_fill_extra_distance_m",
] as const;
const PRESERVED_KEYS = [
"status",
"pavement_suggested",
...USER_TOUCHED_KEYS,
...SPOIL_KEYS,
] as const;
function preserveUserFields(
next: NonNullable<CrossSection["design"]>,
@@ -271,6 +287,9 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
twoStageSlope:
choice?.two_stage_slope ??
(design.two_stage_slope === undefined ? true : Boolean(design.two_stage_slope)),
// ⚠ **선택과 결과를 갈라 넘긴다**(2026-09-09). 선택이 있으면 그것을 따르고,
// 없으면 옛 저장분의 결과를 **자동값과 다를 때만** 선택으로 살린다.
ditchChoice: typeof design.ditch_choice === "boolean" ? design.ditch_choice : null,
ditchEnabled: typeof design.ditch_enabled === "boolean" ? design.ditch_enabled : null,
// 세월교 월류 하강은 계획선 편집으로 바뀌지 않는다 — 저장분 값을 그대로 잇는다.
surfaceDropM: typeof design.surface_drop_m === "number" ? design.surface_drop_m : 0,
@@ -281,6 +300,19 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
? section.curve_outer_side
: null,
curveWideningM: section.curve_widening_m ?? null,
// 사토장 — **저장분 폭을 그대로 잇는다**. 폭은 구간 용량에서 서버가 정한 값이라
// 브라우저가 다시 풀지 않는다(다시 풀면 그 측점만 폭이 달라져 작업장 모양이 깨진다).
// 이어 붙이지 않으면 계획선을 만질 때마다 사토장이 그림에서 사라진다.
spoilFill:
typeof design.spoil_fill_width_m === "number" &&
design.spoil_fill_width_m > 0 &&
(design.spoil_fill_side === "left" || design.spoil_fill_side === "right")
? {
side: design.spoil_fill_side,
widthM: design.spoil_fill_width_m,
slopeRatioN: null,
}
: null,
},
);
} catch {
+115
View File
@@ -85,3 +85,118 @@ def _split_cut_areas(
soil_area += (min(max(d_a, 0.0), t0) + min(max(d_b, 0.0), t0)) / 2.0 * span
rock_area += (max(d_a - t0, 0.0) + max(d_b - t0, 0.0)) / 2.0 * span
return soil_area, rock_area
# 층따기 대상 판정 기울기 — 원지반 횡단기울기 1:4(=25%)보다 급한 곳에만 한다.
# 근거: 임도설치 및 관리 등에 관한 규정 별표2 · 임도기술교본 6장 4절 「경사지의 층따기에
# 있어 그 경사가 1:4보다 급한 경사를 가진 지반 위에 성토를 하는 경우 … 층따기를 설치」.
# 지식DB `01_임도/02_상세설계/성토_비탈면.md` §4 [구현] 「원지반 횡단경사 > 25% 구간의 성토부」.
_BENCH_CUT_MIN_GROUND_SLOPE = 0.25
def _bench_cut_length(offsets: list[float], grounds: list[float], diffs: list[float]) -> float:
"""층따기 밑수 — **성토부 아래 원지반 표면의 경사길이(m)**.
무엇을 재나
성토(diff<0)가 원지반에 얹히는 구간에서, 원지반 횡단기울기가 1:4 보다 급한
조각만 골라 **지표면을 따라간 길이**를 더한다. 수평 폭이 아니라 빗변이다 —
층따기는 그 경사면을 계단으로 깎는 일이라 대상 면이 곧 지표면이다.
왜 성토면이 아니라 원지반인가
층따기는 **원지반 표면**에 하는 것이다(교본 6장 4절). 성토 비탈면 길이로 재면
대상이 아닌 면을 세는 것이 된다.
단위
여기서 나오는 것은 **길이(m)** 다. 면적(㎡)은 측점 사이를 평균단면적법으로 이어
B08 이 낸다 — 사면 4계열과 같은 방식이라 계산을 두 벌로 짜지 않는다.
(2026-09-09 사용자 확정: 층따기 단위는 ㎡.)
"""
total = 0.0
for index in range(1, len(offsets)):
run = offsets[index] - offsets[index - 1]
if run <= 0:
continue
d0, d1 = diffs[index - 1], diffs[index]
# 성토 조각만 — 부호가 바뀌면 영교점까지만 성토다.
if d0 >= 0 and d1 >= 0:
continue
share = 1.0
if d0 * d1 < 0:
zero_ratio = d0 / (d0 - d1)
share = (1.0 - zero_ratio) if d0 > 0 else zero_ratio
if share <= 0:
continue
rise = grounds[index] - grounds[index - 1]
if abs(rise) / run < _BENCH_CUT_MIN_GROUND_SLOPE:
continue
total += ((run**2 + rise**2) ** 0.5) * share
return total
def _split_ditch_area(ditch_spec: dict, depth_to_boundary_m: float | None) -> tuple[float, float]:
"""측구 단면적을 (토사, 암반)으로 가른다 — 암반 경계선까지의 깊이 기준.
`depth_to_boundary_m` 은 **측구 상단에서 암반 경계선까지의 깊이(m)** 다.
`None` 이면 가를 근거가 없다는 뜻이라 부르는 쪽이 처리한다(여기서는 안 부른다).
⚠ 측구 단면은 **공칭 도형**(사다리꼴·L형 근사)이라 지반선을 따라 적분하지 않는다.
경계선도 그 자리 한 높이로 본다 — 폭 1m 안팎에서 지반선 기울기 차이는 도형 근사보다
작다. 절토 면적 분리(`_split_cut_areas`)가 균일 두께를 쓰는 것과 같은 태도다.
"""
kind = str(ditch_spec.get("type") or "none")
if kind == "l_type":
width = float(ditch_spec.get("width_m") or 0.0)
depth = float(ditch_spec.get("depth_m") or 0.0)
total = width * depth / 2.0
if depth <= 0 or width <= 0:
return 0.0, 0.0
d0 = min(max(depth_to_boundary_m or 0.0, 0.0), depth)
# 깊이 d 에서의 가로 폭 = W(1 − d/D). 위에서 d0 까지 적분한다.
soil = width * d0 - width * d0 * d0 / (2.0 * depth)
return soil, max(total - soil, 0.0)
if kind == "standard":
top = float(ditch_spec.get("top_width_m") or 0.0)
bottom = min(float(ditch_spec.get("bottom_width_m") or 0.0), top)
depth = float(ditch_spec.get("depth_m") or 0.0)
total = (top + bottom) / 2.0 * depth
if depth <= 0 or top <= 0:
return 0.0, 0.0
d0 = min(max(depth_to_boundary_m or 0.0, 0.0), depth)
# 깊이 d 에서의 폭 = top (topbottom)·d/depth. 위에서 d0 까지 적분한다.
soil = top * d0 - (top - bottom) * d0 * d0 / (2.0 * depth)
return soil, max(total - soil, 0.0)
return 0.0, 0.0
def _fill_area_beyond(offsets: list[float], diffs: list[float], x0: float, side: str) -> float:
"""`x0` **바깥쪽**(사토장이 서는 쪽)의 성토 면적(㎡)만 따로 낸다.
⚠ 왜 있나 — 사토장이 선 측점에서는 노면 끝 바깥이 **사토장 몫**이라 노선 성토
(`fill_area_m2`)에서 빼야 한다. 안 빼면 **같은 흙을 두 번 센다**(2026-09-09 확정 ㉠).
⚠ 경계(`x0`)의 종거는 **보간해서** 넣는다 — 그냥 버리면 경계 한 칸이 통째로 빠져
값이 작아진다. 좌는 `x0` 위쪽, 우는 `x0` 아래쪽이며 **둘 다 오름차순**으로 넘긴다.
짝: TS `fillAreaBeyond`.
"""
if len(offsets) < 2:
return 0.0
inside = (lambda x: x >= x0) if side == "left" else (lambda x: x <= x0)
sub_offsets: list[float] = []
sub_diffs: list[float] = []
for index, x in enumerate(offsets):
if index > 0:
x_prev = offsets[index - 1]
crosses = (x_prev < x0 < x) or (x < x0 < x_prev)
if crosses:
ratio = (x0 - x_prev) / (x - x_prev)
sub_offsets.append(x0)
sub_diffs.append(diffs[index - 1] + (diffs[index] - diffs[index - 1]) * ratio)
if inside(x):
sub_offsets.append(x)
sub_diffs.append(diffs[index])
order = sorted(range(len(sub_offsets)), key=lambda i: sub_offsets[i])
sub_offsets = [sub_offsets[i] for i in order]
sub_diffs = [sub_diffs[i] for i in order]
if len(sub_offsets) < 2:
return 0.0
return _trapezoid_areas(sub_offsets, sub_diffs)[1]
+27 -1
View File
@@ -213,6 +213,12 @@ def _culvert_set(options: dict[str, Any] | None) -> dict[str, Any]:
"pipe_kind": str(kind) if kind else None,
"diameter_m": round((diameter_mm or 1000.0) / 1000.0, 3),
"min_cover_m": MIN_PIPE_COVER_M,
# 관 기슭막이 기초 축 — **유입·유출 한 칸으로** 받는다(2026-09-09). 한쪽만 기초유로
# 하는 일이 드물어 폼을 둘로 늘리지 않았다. 실무가 갈라 쓰면 그때 나눌 것.
"foundation": (
str(values.get("revet_foundation") or defaults.get("revet_foundation") or "").strip()
or None
),
"inlet": _side_spec(values, defaults, "inlet"),
"outlet": _side_spec(values, defaults, "outlet"),
}
@@ -263,6 +269,8 @@ def _revet_set(options: dict[str, Any] | None) -> dict[str, Any]:
"hidden_pipe": True,
"side": str(values.get("side") or "양쪽"),
"tiers": int(_number(values.get("tiers"), 1.0) or 1),
# 기초 축(기초유/기초버림) — 터파기 그림·물량이 같이 쓴다. 비어 있으면 안 그린다.
"foundation": (str(values.get("foundation") or "").strip() or None),
"pipe_kind": None,
"diameter_m": 0.3,
"min_cover_m": 0.0,
@@ -423,6 +431,24 @@ def attach_culvert_sets(project_root: Path, cross_sections: list[dict[str, Any]]
if not sets:
return 0
attached = 0
# ⚠⚠ **관 자리와 측점 자리는 최대 0.5m 어긋난다 — 그것이 설계다**(2026-09-09 실측).
# 측점을 만들 때 정수 미터가 같은 격자 측점이 있으면 그리로 스냅한다
# (`B05_Profile_Engine_Sections_Core` 의 파일명 가드). 관 440.241 은 **측점 440.0** 위에 선다.
# ⇒ 0.02m 로만 보면 그런 관은 **어느 측점에도 안 붙어** 횡단도에 안 서고 길이도 안 실려
# B08 이 「연장 없음」으로 막는다(실측: 배수관 넷이 그렇게 금액에서 빠져 있었다).
# ⇒ **가장 가까운 측점 하나**는 거리와 무관하게 그 관의 자리로 본다. 하나만 고르므로
# 두 번 세지 않고, 스냅 폭이 바뀌어도 따라간다.
owner: dict[float, float] = {}
for pipe_chainage in sets:
nearest = None
for section in cross_sections:
value = _number(section.get("chainage_m"), None)
if value is None:
continue
if nearest is None or abs(value - pipe_chainage) < abs(nearest - pipe_chainage):
nearest = value
if nearest is not None:
owner[pipe_chainage] = nearest
for section in cross_sections:
chainage = _number(section.get("chainage_m"), None)
if chainage is None:
@@ -432,7 +458,7 @@ def attach_culvert_sets(project_root: Path, cross_sections: list[dict[str, Any]]
reach = _CHAINAGE_TOLERANCE_M
if spec.get("type") in _SPAN_LINKED_TYPES:
reach += (_number(spec.get("span_m"), 0.0) or 0.0) / 2
if abs(chainage - pipe_chainage) <= reach:
if abs(chainage - pipe_chainage) <= reach or owner.get(pipe_chainage) == chainage:
# 세월교·BOX암거·물넘이는 배수관과 그림이 달라 키를 나눈다 — 소비처가 섞이지 않는다.
kind = spec.get("type")
# ⚠ 스펙에 **그 시설이 놓인 누가거리**를 함께 얹는다(2026-09-08). 세트는 폭의
+92 -411
View File
@@ -27,20 +27,22 @@
경사비는 수평:수직 = ratio:1 (예: 1:1.2 → ratio=1.2).
"""
import math
from collections.abc import Callable
from typing import Any
from B06_Section.B06_Section_Engine_Areas import (
_bench_cut_length,
_fill_area_beyond,
_split_cut_areas,
_split_ditch_area,
_trapezoid_areas,
)
from B06_Section.B06_Section_Engine_Design_Geometry import (
_SectionGeometry,
)
from common_util.common_util_cross_berm import (
BermSpec,
cut_profile_points,
fill_profile_points,
)
from common_util.common_util_cross_berm import elevation_at as berm_elevation_at
from common_util.common_util_spoil_fill import spoil_fill_section
from config.config_system import (
CURVE_WIDENING_MAX_WIDTH_M,
SECTION_DITCH_SIDES,
@@ -57,19 +59,6 @@ from config.config_system import (
_SLOPE_CLOSE_TOLERANCE_M = 0.01
def _side_role(section_mode: str) -> tuple[str, str]:
"""단면유형 → (좌측 역할, 우측 역할). 역할은 'cut' 또는 'fill'."""
if section_mode == "left_cut":
return "cut", "fill"
if section_mode == "right_cut":
return "fill", "cut"
if section_mode == "both_cut":
return "cut", "cut"
if section_mode == "both_fill":
return "fill", "fill"
raise ValueError(f"지원하지 않는 단면유형입니다: {section_mode}")
def _resolve_ditch_side(section_mode: str, ditch_side: str | None) -> str:
"""측구(배수) 배치 측을 결정한다.
@@ -152,398 +141,6 @@ def _ground_interpolator(valid: list[tuple[float, float]]):
return ground_at
class _SectionGeometry:
"""설계선 피스와이즈 평가기. 노면 → 측구 → 사면 순으로 offset의 설계고를 계산한다."""
def __init__(
self,
*,
design_elevation_m: float,
group: dict[str, float],
section_mode: str,
ditch_side: str,
ditch_type: str,
cross_slope_pct: float,
ground_at: Callable[[float], float] | None = None,
soil_cut_ratio: float | None = None,
rock_boundary_offset_m: float | None = None,
two_stage_slope: bool = False,
ditch_enabled: bool | None = None,
widening_left_m: float = 0.0,
widening_right_m: float = 0.0,
berm: BermSpec | None = None,
) -> None:
half_road = group["road_width_m"] / 2.0
# 곡선부 확폭은 **한쪽으로만** 붙는다(2026-09-06 사용자 확정: 곡선 바깥쪽).
# 그래서 반폭을 좌·우로 나눠 든다 — 확폭이 0이면 예전과 똑같은 대칭 단면이다.
self.half_road_left = half_road + max(widening_left_m, 0.0)
self.half_road_right = half_road + max(widening_right_m, 0.0)
self.half_road = half_road # 규격 차도 반폭(확폭 전) — 수량·표기 기준
self.left_extent = self.half_road_left + group["shoulder_left_m"] # 좌(+) 노면 끝
self.right_extent = self.half_road_right + group["shoulder_right_m"] # 우(-) 노면 끝
self.z_center = design_elevation_m
self.cut_ratio = max(group["cut_slope_ratio"], 1e-6) # 암 구간(하단) 절토 경사
self.fill_ratio = max(group["fill_slope_ratio"], 1e-6)
self.left_role, self.right_role = _side_role(section_mode)
self.ditch_side = ditch_side
# 2단계 절토: 암반 경계선(지반선 + rock_boundary_offset) 아래는 암 경사(cut_ratio),
# 위는 토사 경사(soil_cut_ratio)를 쓴다. 경계 아래→위 전환점(무릎)을 측별로 미리 구한다.
self.soil_cut_ratio = max(soil_cut_ratio or group["cut_slope_ratio"], 1e-6)
self.two_stage = bool(
two_stage_slope and ground_at is not None and rock_boundary_offset_m is not None
)
self._ground_at = ground_at
self._rock_offset = rock_boundary_offset_m or 0.0
# 소단 제원(없으면 None) — 절토 사면 꼭짓점 셈에 그대로 넘어간다.
self.berm = berm
self._cut_points_cache: dict[str, list[tuple[float, float]]] = {}
self._fill_points_cache: dict[str, list[tuple[float, float]]] = {}
# 절토 사면·지반 최초 교차거리(측별 캐시) — 교차 후 절토 종료용(N-2-4).
self._cut_cross: dict[str, float | None] = {}
self._fill_cross: dict[str, float | None] = {}
self.ditch_type = ditch_type
# 횡단경사: 측구 방향으로 내려가는 단일 사면 (좌=+offset 규약).
slope = cross_slope_pct / 100.0
self.slope_per_offset = -slope if ditch_side == "left" else slope
# 단면 유형 자동 판정(D-2): 각 측 절/성토 역할을 노면 끝 지반이 설계면보다
# 높은지(절토)/낮은지(성토)로 결정한다. 좌절/우절/양절/양성이 모두 지형에서
# 자연 도출된다. 사용자 입력 section_mode는 측구 방향(ditch_side) 기본값에만 쓰고
# 절/성토 역할은 손대지 않는다. ground_at이 없으면 section_mode 기반 역할을 쓴다.
if ground_at is not None:
self.left_role = (
"cut"
if ground_at(self.left_extent) > self.road_z(self.left_extent) + 1e-3
else "fill"
)
self.right_role = (
"cut"
if ground_at(-self.right_extent) > self.road_z(-self.right_extent) + 1e-3
else "fill"
)
# 측구 생성 여부(D-1): 양성은 항상 미생성. ditch_enabled가 오면 그 값을 따르고(수동
# override), None이면 자동 판정 — 측구측 노면 끝에서 지반이 설계면보다 높으면(절토
# 상황) 생성, 낮으면(성토 상황, 자연 배수) 미생성. ground_at 없으면 보수적으로 생성.
if section_mode == "both_fill":
self.has_ditch = False
elif ditch_enabled is not None:
self.has_ditch = ditch_enabled
elif ground_at is not None:
ditch_edge = self.left_extent if ditch_side == "left" else -self.right_extent
self.has_ditch = ground_at(ditch_edge) > self.road_z(ditch_edge) + 1e-3
else:
self.has_ditch = True
# 측구 꼭짓점(측구측 노면 끝 기준, 바깥 방향 부호 적용).
self.ditch_points: list[tuple[float, float]] = []
edge_offset = self.left_extent if ditch_side == "left" else -self.right_extent
outward = 1.0 if ditch_side == "left" else -1.0
edge_z = self.road_z(edge_offset)
if self.has_ditch:
if ditch_type == "l_type":
# L형: 노면 끝에서 폭 W 동안 깊이 D로 내려가는 경사 바닥 + 바깥 수직벽.
# 바깥(노견 반대측) 상단은 **노견과 같은 표고**로 닫는다 — 사면 시작점이
# 측구 바닥 높이로 내려가면 안 된다(2026-08-23 사용자 지시).
width = group["l_ditch_width_m"]
depth = group["l_ditch_depth_m"]
self.ditch_points = [
(edge_offset, edge_z),
(edge_offset + outward * width, edge_z - depth),
(edge_offset + outward * width, edge_z),
]
else:
# 일반: 상단폭/저폭/깊이 사다리꼴.
top = group["ditch_top_width_m"]
bottom = min(group["ditch_bottom_width_m"], top)
depth = group["ditch_depth_m"]
inset = (top - bottom) / 2.0
self.ditch_points = [
(edge_offset, edge_z),
(edge_offset + outward * inset, edge_z - depth),
(edge_offset + outward * (inset + bottom), edge_z - depth),
(edge_offset + outward * top, edge_z),
]
def road_z(self, offset_m: float) -> float:
"""노면(노견 포함) 설계고 — 중심 계획고에서 횡단경사로 기운 단일 평면."""
return self.z_center + self.slope_per_offset * offset_m
def _slope_start(self, side: str) -> tuple[float, float]:
"""사면 시작점(오프셋 절대값 기준 거리, 표고)을 계산한다."""
if side == "left":
edge_offset, edge_z = self.left_extent, self.road_z(self.left_extent)
else:
edge_offset, edge_z = self.right_extent, self.road_z(-self.right_extent)
if side == self.ditch_side and self.ditch_points:
outer = self.ditch_points[-1]
return abs(outer[0]), outer[1]
return edge_offset, edge_z
def _rock_boundary_z(self, side: str, dist: float) -> float:
"""측·거리(절대 오프셋)에서 암반 경계선 표고 = 지반선 + 오프셋(음수=하향)."""
signed = dist if side == "left" else -dist
assert self._ground_at is not None # two_stage일 때만 호출
return self._ground_at(signed) + self._rock_offset
def cut_points(self, side: str) -> list[tuple[float, float]]:
"""절토 사면 꼭짓점 `[(거리, 표고), ...]` — 무릎과 소단이 모두 여기 들어 있다.
셈은 짝 모듈 `common_util_cross_berm` 한 벌이 한다(TS 도 같은 것을 부른다).
소단이 없으면 종전 무릎 방식과 **같은 값**이다(동치 시험으로 지킨다).
"""
if side in self._cut_points_cache:
return self._cut_points_cache[side]
start_dist, start_z = self._slope_start(side)
boundary = (lambda dist: self._rock_boundary_z(side, dist)) if self.two_stage else None
points = cut_profile_points(
start_dist,
start_z,
self.cut_ratio,
self.soil_cut_ratio,
boundary,
self.berm,
# 소단이 있으면 경계를 오갈 때마다 꺾는다 — 소단은 평탄한데 경계선은 지반을
# 따라 올라가서 되돌아 들어가는 일이 흔하다. 한 번만 꺾으면 그 구간을 암인데
# 토사 경사로 그려 절토가 조용히 커진다(2026-09-07).
multi_knee=self.berm is not None,
)
self._cut_points_cache[side] = points
return points
def _cut_slope_z(self, side: str, dist: float) -> float:
"""절토 사면선 표고(무릎·소단 반영). 지반 교차 클램프는 하지 않는다."""
return berm_elevation_at(self.cut_points(side), dist)
def fill_points(self, side: str) -> list[tuple[float, float]]:
"""성토 사면 꼭짓점 — 소단이 들어 있다. 절토와 달리 무릎은 없다."""
if side in self._fill_points_cache:
return self._fill_points_cache[side]
start_dist, start_z = self._slope_start(side)
points = fill_profile_points(start_dist, start_z, self.fill_ratio, self.berm)
self._fill_points_cache[side] = points
return points
def _fill_slope_z(self, side: str, dist: float) -> float:
"""성토 사면선 표고(소단 반영). 지반 교차 클램프는 하지 않는다."""
return berm_elevation_at(self.fill_points(side), dist)
def cut_slope_segments(self) -> list[dict[str, Any]]:
"""절토 사면을 **경사 구간별로** 쪼갠 목록.
⚠ **지금 이 값을 읽는 곳은 없다**(2026-09-07). 임자였던 별표2 법정 경사 검사가 폐기됐고
(암질을 횡단도에서 안 고르기로 사용자 확정), 저장분에도 안 들어간다. 소단 기하가 이 셈
위에 서 있어 남겨 둔다 — **되살릴 때는 저장분에서 읽지 말고 계산해서 쓸 것.**
원래 필요했던 까닭(되살릴 때 그대로 유효) — 소단이 서면 사면 전체를 하나로 재는
「실효 경사」가 완만해져 **위반이 사라진 것처럼** 보인다(폭 1.0·간격 2 이면 설계 1:1 이
실효 1:1.71). 검사는 소단을 뺀 **사면 구간 자체의 경사**를 봐야 한다.
· 평탄부(소단)는 싣지 않는다 — 경사 구간이 아니고 경사비가 무한대가 된다.
· 지반과 만난 뒤 구간도 싣지 않는다 — 절토가 아니다.
· `material` 은 암반 경계 기준 `rock`/`soil`. 경계를 모르면(2단계 아님) None.
암을 다시 가르는 값은 측점의 `cut_rock_kind` 를 읽는다(구간에 싣지 않는다).
"""
segments: list[dict[str, Any]] = []
for side in ("left", "right"):
role = self.left_role if side == "left" else self.right_role
if role != "cut":
continue
cross = self.cut_cross_dist(side)
points = self.cut_points(side)
sign = 1.0 if side == "left" else -1.0
for index in range(1, len(points)):
start_d, start_z = points[index - 1]
end_d, end_z = points[index]
if cross is not None and start_d >= cross - 1e-9:
break # 지반과 만난 뒤는 절토가 없다
if cross is not None and end_d > cross:
# 지반과 만나는 점에서 구간을 자른다.
end_z = berm_elevation_at(points, cross)
end_d = cross
run = end_d - start_d
rise = end_z - start_z
if run <= 1e-9 or rise <= 1e-6:
continue # 길이 0·역방향은 검사 대상이 아니다
if self.berm is not None and abs(run - self.berm.width_m) < 1e-6:
# 소단(평탄부) — 폭이 딱 맞고 오름이 기울기(2°)만큼이면 그것이다.
berm_rise = math.tan(math.radians(self.berm.slope_deg)) * self.berm.width_m
if abs(rise - berm_rise) < 1e-9:
continue
# 재료는 **그 구간을 실제로 그린 경사비**로 가른다 — 경계선을 다시 재면
# 안 된다. 무릎을 지난 뒤에도 경계선은 지반을 따라 계속 오르므로, 토사
# 경사로 그린 구간이 경계 아래로 되돌아가 있는 일이 흔하다. 그것을 경계로
# 재면 「경사비는 토사인데 재료는 암」인 구간이 생긴다(2026-09-07 다른 창
# 실측: 용화 63측점에서 13구간). 그린 대로 적는 것이 맞다.
material: str | None = None
if self.two_stage and abs(self.soil_cut_ratio - self.cut_ratio) > 1e-9:
drawn = run / rise
material = (
"soil"
if abs(drawn - self.soil_cut_ratio) < abs(drawn - self.cut_ratio)
else "rock"
)
segments.append(
{
"side": side,
"ratio": round(run / rise, 4),
"rise_m": round(rise, 4),
"run_m": round(run, 4),
"start_offset_m": round(sign * start_d, 4),
"end_offset_m": round(sign * end_d, 4),
"material": material,
}
)
return segments
def cut_cross_dist(self, side: str) -> float | None:
"""절토 사면이 지반선과 처음 만나는 거리(절대 오프셋). 이후는 절토 없음(N-2-4).
지면과 1회 교차하면 그다음 경사(2단계 전환 포함)는 의미가 없으므로 교차점에서
절토를 종료한다. 시작(노면 끝)부터 사면이 지반 위면 교차거리=시작(절토 없음),
끝까지 못 만나면 None.
"""
if side in self._cut_cross:
return self._cut_cross[side]
result: float | None = None
if self._ground_at is not None:
start_dist, _start_z = self._slope_start(side)
step = 0.05
dist = start_dist
max_dist = start_dist + 500.0
while dist <= max_dist:
signed = dist if side == "left" else -dist
if self._cut_slope_z(side, dist) - self._ground_at(signed) >= 0:
result = dist
break
dist += step
self._cut_cross[side] = result
return result
def fill_ground_slope(self) -> float | None:
"""성토측 **자연 지반**의 평균 경사(rise/run, 무차원). 성토측이 없으면 None.
자연방토 판정에 쓴다 — 지반이 가파르면 부어 놓은 흙이 쌓이지 않고 흘러내린다.
구간은 노면 끝(사면 시작)부터 성토 사면이 지반과 처음 만나는 곳까지이며, 끝까지
만나지 못하면 10m를 본다. 양쪽이 다 성토면 **완만한 쪽**을 택한다(보수적 판정).
"""
if self._ground_at is None:
return None
slopes: list[float] = []
for side in ("left", "right"):
role = self.left_role if side == "left" else self.right_role
if role != "fill":
continue
start_dist, _start_z = self._slope_start(side)
end_dist = self.fill_cross_dist(side) or (start_dist + 10.0)
run = end_dist - start_dist
if run <= 1e-6:
continue
sign = 1.0 if side == "left" else -1.0
rise = abs(self._ground_at(sign * end_dist) - self._ground_at(sign * start_dist))
slopes.append(rise / run)
return min(slopes) if slopes else None
def fill_cross_dist(self, side: str) -> float | None:
"""성토 사면이 지반선과 **처음** 만나는 거리(절대 오프셋). 이후는 성토 없음.
절토(cut_cross_dist)와 같은 규칙이다. 실제 지면은 울퉁불퉁해서 성토 사면이 지반과
여러 번 만날 수 있는데, **첫 교차점이 성토사면의 끝**이고 그 바깥은 손대지 않은
지반이다(2026-08-02 사용자 지시). `max(fill_line, ground)`만 쓰면 지반이 다시 꺼졌을 때
성토 사면이 되살아나 사면이 끊겼다 이어지는 그림이 나온다.
시작(노면 끝)부터 사면이 지반 아래면 교차거리=시작(성토 없음), 끝까지 못 만나면 None.
"""
if side in self._fill_cross:
return self._fill_cross[side]
result: float | None = None
if self._ground_at is not None:
start_dist, start_z = self._slope_start(side)
step = 0.05
dist = start_dist
max_dist = start_dist + 500.0
while dist <= max_dist:
signed = dist if side == "left" else -dist
fill_line = self._fill_slope_z(side, dist)
if fill_line - self._ground_at(signed) <= 0:
result = dist
break
dist += step
self._fill_cross[side] = result
return result
def design_z(self, offset_m: float, ground_m: float) -> float:
"""offset 하나의 설계 표고(사면은 지반 교차점 이후 지반 추종)."""
side = "left" if offset_m >= 0 else "right"
extent = self.left_extent if side == "left" else self.right_extent
if abs(offset_m) <= extent + 1e-9:
return self.road_z(offset_m)
# 측구 구간: 꼭짓점 사이 선형 보간(지반 무관 강제 굴착).
if side == self.ditch_side and self.ditch_points:
inner = abs(self.ditch_points[0][0])
outer = abs(self.ditch_points[-1][0])
if inner - 1e-9 <= abs(offset_m) <= outer + 1e-9:
points = self.ditch_points
for index in range(1, len(points)):
x0, z0 = abs(points[index - 1][0]), points[index - 1][1]
x1, z1 = abs(points[index][0]), points[index][1]
if abs(offset_m) > x1 + 1e-9:
continue
span = x1 - x0
if span <= 1e-9:
return z1
ratio = (abs(offset_m) - x0) / span
return z0 + (z1 - z0) * ratio
return points[-1][1]
role = self.left_role if side == "left" else self.right_role
dist = abs(offset_m)
if role == "cut":
# 지반과 1회 교차하면 그 이후 절토는 의미 없음 → 지반 추종(N-2-4).
cross = self.cut_cross_dist(side)
if cross is not None and dist >= cross:
return ground_m
return min(self._cut_slope_z(side, dist), ground_m)
# 지반과 1회 교차하면 그 바깥은 성토가 아니라 원지반이다(절토와 같은 규칙).
cross = self.fill_cross_dist(side)
if cross is not None and dist >= cross:
return ground_m
return max(self._fill_slope_z(side, dist), ground_m)
def breakpoints(self) -> list[float]:
"""적분·설계선에 반드시 포함할 설계 꼭짓점 오프셋 목록(2단계 무릎·소단 포함)."""
points = [0.0, self.left_extent, -self.right_extent]
points.extend(offset for offset, _z in self.ditch_points)
# 절토 사면 꼭짓점(무릎·소단 모서리) — 빠뜨리면 계단이 설계선에 안 실린다.
if self.two_stage or self.berm is not None:
for side in ("left", "right"):
role = self.left_role if side == "left" else self.right_role
if role != "cut":
continue
cross = self.cut_cross_dist(side)
for offset, _z in self.cut_points(side):
if cross is not None and offset > cross + 1e-9:
break # 지반과 만난 뒤는 절토가 없다
points.append(offset if side == "left" else -offset)
# 성토 사면 소단 모서리 — 절토와 같은 까닭으로 설계선에 실어야 계단이 그려진다.
if self.berm is not None:
for side in ("left", "right"):
role = self.left_role if side == "left" else self.right_role
if role != "fill":
continue
cross = self.fill_cross_dist(side)
for offset, _z in self.fill_points(side):
if cross is not None and offset > cross + 1e-9:
break
points.append(offset if side == "left" else -offset)
# 절·성토 사면과 지반의 **첫** 교차점을 꼭짓점에 넣어 면적 절단을 정확히 한다(N-2-4).
for side in ("left", "right"):
role = self.left_role if side == "left" else self.right_role
cross = self.cut_cross_dist(side) if role == "cut" else self.fill_cross_dist(side)
if cross is not None:
points.append(cross if side == "left" else -cross)
return points
def curve_widening_args(section: dict[str, Any] | None) -> dict[str, Any]:
"""측점 기록에서 곡선부 확폭 입력을 뽑는다 — `compute_cross_design(**...)` 로 넘긴다.
@@ -574,11 +171,13 @@ def compute_cross_design(
two_stage_slope: bool = True,
cut_slope_ratio: float | None = None,
ditch_enabled: bool | None = None,
ditch_choice: bool | None = None,
surface_drop_m: float = 0.0,
plan_radius_m: float | None = None,
curve_outer_side: str | None = None,
curve_widening_m: float | None = None,
berm: BermSpec | None = None,
spoil_fill: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""측점 하나의 표준횡단 설계선과 절·성토 단면적을 계산한다.
@@ -598,6 +197,10 @@ def compute_cross_design(
surface_drop_m: 노면을 통째로 내리는 양(m) — 세월교 월류 높이. 구체 위 노면은 월류
높이만큼 낮게 앉으므로 계획고를 그만큼 내려 잡는다. 단면 전체가 평행 이동하므로
횡단경사·측구·사면 규칙은 그대로고 절·성토 면적만 따라 바뀐다(2026-08-30 사용자).
spoil_fill: 이 측점에 선 유용토운반작업장(구 사토장) — `{"side", "width_m", "slope_ratio_n"}`.
폭은 **노면 끝**(노견이 시작하는 자리)에서 재고, 그 바깥 성토는 **노선 몫이 아니라
사토장 몫**이라 `fill_area_m2` 에서 뺀다(2026-09-09 확정 ㉠ — 두 번 세지 않기).
`slope_ratio_n` 이 비면 그 측점의 **노선 성토 기울기**를 그대로 쓴다.
"""
if ground_type not in SECTION_GROUND_TYPE_PRESET:
raise ValueError(f"지원하지 않는 지반유형입니다: {ground_type}")
@@ -670,7 +273,9 @@ def compute_cross_design(
soil_cut_ratio=soil_cut_ratio,
rock_boundary_offset_m=rock_boundary_offset_m,
two_stage_slope=enable_two_stage,
ditch_enabled=ditch_enabled,
# 선택은 `ditch_choice`, 옛 저장분은 `ditch_enabled` 로 온다(자동값과 다를 때만 뜻).
ditch_choice=ditch_choice,
legacy_ditch_enabled=ditch_enabled,
widening_left_m=widening_left,
widening_right_m=widening_right,
berm=berm,
@@ -684,17 +289,22 @@ def compute_cross_design(
merged = sorted(set(round(offset, 6) for offset in merged))
offsets: list[float] = []
grounds: list[float] = []
diffs: list[float] = []
design_line: list[dict[str, float]] = []
for offset_m in merged:
ground_m = ground_at(offset_m)
design_z = geometry.design_z(offset_m, ground_m)
offsets.append(offset_m)
grounds.append(ground_m)
diffs.append(ground_m - design_z)
design_line.append({"offset_m": round(offset_m, 4), "elevation_m": round(design_z, 4)})
# 측구 굴착은 설계선에 포함돼 절토 면적에 자연 반영된다(별도 가산 없음 — 이중계상 방지).
cut_area, fill_area = _trapezoid_areas(offsets, diffs)
# 층따기 밑수(길이 m) — 성토부 아래 원지반이 1:4 보다 급한 구간의 지표면 길이.
# 면적(㎡)은 측점 사이를 평균단면적법으로 이어 B08 이 낸다(2026-09-09 사용자 확정).
bench_cut_length = _bench_cut_length(offsets, grounds, diffs)
fill_ground_slope = geometry.fill_ground_slope()
# 사면이 샘플 범위 끝에서도 원지반과 만나지 않으면 면적이 거기서 잘린다 — 그만큼
# 절·성토량이 실제와 다르고 유토곡선도 그 값을 그대로 쌓는다. 영원히 안 만나는
@@ -703,6 +313,26 @@ def compute_cross_design(
abs(diffs[0]) > _SLOPE_CLOSE_TOLERANCE_M or abs(diffs[-1]) > _SLOPE_CLOSE_TOLERANCE_M
)
# 사토장(유용토운반작업장) — 노면 끝 바깥에 쌓는 성토. 짝: TS `computeCrossDesign`.
# ⚠ 그 바깥 성토는 **노선 몫이 아니다** — 빼지 않으면 같은 흙을 두 번 센다(확정 ㉠).
spoil_section = None
spoil_replaced = 0.0
spoil_side = str((spoil_fill or {}).get("side") or "")
spoil_width = _as_float((spoil_fill or {}).get("width_m"), 0.0)
if spoil_side in ("left", "right") and spoil_width > 0:
spoil_x0 = geometry.half_road_left if spoil_side == "left" else -geometry.half_road_right
spoil_ratio = _as_float((spoil_fill or {}).get("slope_ratio_n"), 0.0) or geometry.fill_ratio
spoil_section = spoil_fill_section(
valid,
spoil_x0,
geometry.road_z(spoil_x0),
spoil_side,
spoil_width,
spoil_ratio,
)
spoil_replaced = _fill_area_beyond(offsets, diffs, spoil_x0, spoil_side)
fill_area = max(fill_area - spoil_replaced, 0.0)
# 절토면적 토사/암반 분리 — 지표면~암반 경계선이 토사, 그 아래가 암이다. 경계선 위치가
# 곧 유토곡선 EA/RR/BR 비율을 만들므로, 사용자가 경계선을 올리내리면 이 값이 함께 바뀐다.
# 토사 지반은 암반 경계선 자체가 없어 전량 토사, 암 지반인데 경계선 값이 없으면(구 데이터)
@@ -743,6 +373,28 @@ def compute_cross_design(
"depth_m": group["ditch_depth_m"],
}
# 측구터파기 토사/암 분리 — **새 입력을 만들지 않는다.** 절토 분리와 같은 근거
# (지반 유형 + 암반 경계선)를 그대로 쓴다. 별표2 Ⅰ.1.나.(5) 「측구터파기 단면적」이
# 횡단도 표의 법정 칸이라 반만 채워 나가면 안 된다(2026-09-09).
# 근거가 없으면 **나누지 않고** 사유를 함께 내보낸다 — 절반을 임의로 가르지 않는다.
if not geometry.has_ditch:
ditch_soil_area, ditch_rock_area = 0.0, 0.0
ditch_split_basis = "no_ditch"
elif preset_key != "rock":
# 토사 지반 — 암반 경계선 자체가 없다. 전량 토사(절토 분리와 같은 판정).
ditch_soil_area, ditch_rock_area = ditch_area, 0.0
ditch_split_basis = "soil_ground"
elif rock_boundary_offset_m is None or not geometry.ditch_points:
# 암 지반인데 경계선 값이 없다(구 데이터) — 가를 근거가 없으므로 전량 암.
ditch_soil_area, ditch_rock_area = 0.0, ditch_area
ditch_split_basis = "rock_ground_no_boundary"
else:
ditch_top_z = geometry.ditch_points[0][1]
mid_offset = sum(point[0] for point in geometry.ditch_points) / len(geometry.ditch_points)
boundary_z = ground_at(mid_offset) - abs(float(rock_boundary_offset_m))
ditch_soil_area, ditch_rock_area = _split_ditch_area(ditch_spec, ditch_top_z - boundary_z)
ditch_split_basis = "rock_boundary"
# 자동 판정된 절/성토 역할에서 실제 단면 유형을 도출해 echo한다(D-2, 표시·저장용).
if geometry.left_role == "cut" and geometry.right_role == "cut":
resolved_mode = "both_cut"
@@ -776,7 +428,10 @@ def compute_cross_design(
"widening_right_m": round(geometry.half_road_right - geometry.half_road, 4),
"cross_slope_pct": round(cross_slope_pct, 4),
"ditch": ditch_spec,
# 결과 — **실제로 섰나**. 읽는 쪽 여섯이 이 뜻으로 쓴다.
"ditch_enabled": bool(geometry.has_ditch),
# 선택 — **사용자가 정한 것**(`None` = 자동). 결과와 갈라 둔다(2026-09-09).
"ditch_choice": geometry.ditch_choice,
"paved": bool(paved),
# 노면(노견 포함) 양 끝점 — 노면 렌더링 기준.
"road_edges": {
@@ -807,6 +462,26 @@ def compute_cross_design(
"cut_rock_area_m2": round(cut_rock_area, 4),
"cut_rock_kind": cut_rock_kind,
"fill_area_m2": round(fill_area, 4),
# 사토장 몫 — **`fill_area_m2` 와 합치지 않는다**(받는 쪽이 갈라 볼 수 있어야 한다).
"spoil_fill_area_m2": round(spoil_section.area_m2, 4) if spoil_section else 0.0,
"spoil_fill_side": spoil_side if spoil_section else None,
"spoil_fill_width_m": round(spoil_width, 4) if spoil_section else 0.0,
"spoil_fill_max_width_m": round(spoil_section.max_width_m, 4) if spoil_section else 0.0,
"spoil_fill_line": (
[
{"offset_m": offset, "elevation_m": elevation}
for offset, elevation in spoil_section.line
]
if spoil_section
else []
),
"spoil_fill_unclosed": bool(spoil_section.unclosed) if spoil_section else False,
# 사토장이 대신 차지해 노선 성토에서 뺀 몫(㎡) — 되짚기용. 합계에 또 넣지 말 것.
"spoil_fill_replaced_fill_m2": round(spoil_replaced, 4),
# 층따기 밑수 — 성토부 아래 원지반(1:4 보다 급한 구간)의 지표면 길이(m).
# B08 이 측점 사이를 이어 ㎡ 로 만든다. 여기서 ㎥ 로 바꾸지 않는다 —
# 단의 높이·폭이 설계도서 값이라 지어낼 수 없다.
"bench_cut_length_m": round(bench_cut_length, 4),
# 사면이 샘플 범위 끝까지 원지반을 못 만나 면적이 잘린 측점 — 경고 표기용.
"slope_unclosed": slope_unclosed,
# 성토측 자연 지반 경사(rise/run) — 자연방토 판정 입력. 성토측이 없으면 None.
@@ -814,6 +489,12 @@ def compute_cross_design(
round(fill_ground_slope, 4) if fill_ground_slope is not None else None
),
"ditch_area_m2": round(ditch_area, 4),
# 측구터파기 내역(합=ditch_area_m2). 가른 근거는 `ditch_split_basis` 로 함께 낸다:
# rock_boundary(암반 경계선으로 가름) · soil_ground(토사 지반이라 전량 토사) ·
# rock_ground_no_boundary(암 지반인데 경계선 없음 — 전량 암) · no_ditch(측구 없음).
"ditch_soil_area_m2": round(ditch_soil_area, 4),
"ditch_rock_area_m2": round(ditch_rock_area, 4),
"ditch_split_basis": ditch_split_basis,
"design_line": design_line,
# 절토 사면을 경사 구간별로 쪼갠 목록(소단 제외).
# ⚠ **지금 이 값을 읽는 곳은 없다**(2026-09-07). 원래 임자였던 별표2 법정 경사 검사는
@@ -0,0 +1,447 @@
"""횡단 설계선의 **기하** — 노면 → 측구 → 사면 순으로 offset 의 설계고를 낸다.
**TS 짝과 같은 선에서 갈랐다** `common_util/common_util_cross_design_geometry.ts`.
TS 2026-09-04 이미 선으로 떨어져 있었고, 파이썬만 파일에 붙어 있어
924줄이 됐다(700 제한 초과). **새로 긋는 선이 아니라 TS 있던 선을 이쪽에도 그은 **
이라 거울 시험(`tmp/tests/test_b06_cross_design_mirror.py`) 그대로 돈다.
같이 옮긴 `_side_role`(TS `sideRole`). 클래스만 쓰는 도우미다.
옮긴 측구 방향 해석·지반 보간·사면 폐합 허용오차는 TS 본체에 두었다.
계산은 ** 줄도 바꾸지 않았다.** 옮기기만 것이라, 값이 달라지면 옮기다 흘린 것이다.
"""
from __future__ import annotations
import math
from collections.abc import Callable
from typing import Any
from common_util.common_util_cross_berm import (
BermSpec,
cut_profile_points,
fill_profile_points,
)
from common_util.common_util_cross_berm import elevation_at as berm_elevation_at
def _side_role(section_mode: str) -> tuple[str, str]:
"""단면유형 → (좌측 역할, 우측 역할). 역할은 'cut' 또는 'fill'."""
if section_mode == "left_cut":
return "cut", "fill"
if section_mode == "right_cut":
return "fill", "cut"
if section_mode == "both_cut":
return "cut", "cut"
if section_mode == "both_fill":
return "fill", "fill"
raise ValueError(f"지원하지 않는 단면유형입니다: {section_mode}")
class _SectionGeometry:
"""설계선 피스와이즈 평가기. 노면 → 측구 → 사면 순으로 offset의 설계고를 계산한다."""
def __init__(
self,
*,
design_elevation_m: float,
group: dict[str, float],
section_mode: str,
ditch_side: str,
ditch_type: str,
cross_slope_pct: float,
ground_at: Callable[[float], float] | None = None,
soil_cut_ratio: float | None = None,
rock_boundary_offset_m: float | None = None,
two_stage_slope: bool = False,
ditch_choice: bool | None = None,
legacy_ditch_enabled: bool | None = None,
widening_left_m: float = 0.0,
widening_right_m: float = 0.0,
berm: BermSpec | None = None,
) -> None:
half_road = group["road_width_m"] / 2.0
# 곡선부 확폭은 **한쪽으로만** 붙는다(2026-09-06 사용자 확정: 곡선 바깥쪽).
# 그래서 반폭을 좌·우로 나눠 든다 — 확폭이 0이면 예전과 똑같은 대칭 단면이다.
self.half_road_left = half_road + max(widening_left_m, 0.0)
self.half_road_right = half_road + max(widening_right_m, 0.0)
self.half_road = half_road # 규격 차도 반폭(확폭 전) — 수량·표기 기준
self.left_extent = self.half_road_left + group["shoulder_left_m"] # 좌(+) 노면 끝
self.right_extent = self.half_road_right + group["shoulder_right_m"] # 우(-) 노면 끝
self.z_center = design_elevation_m
self.cut_ratio = max(group["cut_slope_ratio"], 1e-6) # 암 구간(하단) 절토 경사
self.fill_ratio = max(group["fill_slope_ratio"], 1e-6)
self.left_role, self.right_role = _side_role(section_mode)
self.ditch_side = ditch_side
# 2단계 절토: 암반 경계선(지반선 + rock_boundary_offset) 아래는 암 경사(cut_ratio),
# 위는 토사 경사(soil_cut_ratio)를 쓴다. 경계 아래→위 전환점(무릎)을 측별로 미리 구한다.
self.soil_cut_ratio = max(soil_cut_ratio or group["cut_slope_ratio"], 1e-6)
self.two_stage = bool(
two_stage_slope and ground_at is not None and rock_boundary_offset_m is not None
)
self._ground_at = ground_at
self._rock_offset = rock_boundary_offset_m or 0.0
# 소단 제원(없으면 None) — 절토 사면 꼭짓점 셈에 그대로 넘어간다.
self.berm = berm
self._cut_points_cache: dict[str, list[tuple[float, float]]] = {}
self._fill_points_cache: dict[str, list[tuple[float, float]]] = {}
# 절토 사면·지반 최초 교차거리(측별 캐시) — 교차 후 절토 종료용(N-2-4).
self._cut_cross: dict[str, float | None] = {}
self._fill_cross: dict[str, float | None] = {}
self.ditch_type = ditch_type
# 횡단경사: 측구 방향으로 내려가는 단일 사면 (좌=+offset 규약).
slope = cross_slope_pct / 100.0
self.slope_per_offset = -slope if ditch_side == "left" else slope
# 단면 유형 자동 판정(D-2): 각 측 절/성토 역할을 노면 끝 지반이 설계면보다
# 높은지(절토)/낮은지(성토)로 결정한다. 좌절/우절/양절/양성이 모두 지형에서
# 자연 도출된다. 사용자 입력 section_mode는 측구 방향(ditch_side) 기본값에만 쓰고
# 절/성토 역할은 손대지 않는다. ground_at이 없으면 section_mode 기반 역할을 쓴다.
if ground_at is not None:
self.left_role = (
"cut"
if ground_at(self.left_extent) > self.road_z(self.left_extent) + 1e-3
else "fill"
)
self.right_role = (
"cut"
if ground_at(-self.right_extent) > self.road_z(-self.right_extent) + 1e-3
else "fill"
)
# 측구 생성 여부(D-1) — **자동 판정이 먼저, 사용자 선택이 그 위**(2026-09-09 정리).
#
# ⚠⚠ **한 칸에 두 뜻이 담겨 있던 자리다.** 결과(`ditch_enabled` = 실제 생성됨)를
# 그대로 다시 입력으로 넣어 읽었으므로, **한 번 저장되면 자동 판정이 영영 다시
# 안 돌았다.** 계획고를 내려 절토가 생겨도 측구가 안 서고 **아무 말도 안 나왔다.**
# ⇒ 이제 **선택은 `ditch_choice`**(없음 = 자동)이고 **결과는 `ditch_enabled`** 다.
# ⚠ 옛 저장분(`ditch_choice` 가 없던 것)은 `legacy_ditch_enabled` 로 온다. 그 값은
# **자동값과 다를 때만 뜻이 있다** — 같으면 자동이 그렇게 냈던 것이고, 다르면
# 사용자가 일부러 바꾼 것이다. 그래서 **다를 때만** 선택으로 살린다(설계 의도 보존).
if section_mode == "both_fill":
auto_ditch = False
elif ground_at is not None:
ditch_edge = self.left_extent if ditch_side == "left" else -self.right_extent
auto_ditch = ground_at(ditch_edge) > self.road_z(ditch_edge) + 1e-3
else:
auto_ditch = True # ground_at 이 없으면 보수적으로 생성
choice = ditch_choice
if choice is None and legacy_ditch_enabled is not None:
choice = bool(legacy_ditch_enabled)
# ⚠ **자동과 같은 값을 고른 것은 「자동」으로 본다**(2026-09-09). 화면 토글이 2단이라
# 「자동으로 되돌리기」 단추가 없다 — 되돌리려면 원래 값으로 다시 누르는 수밖에
# 없는데, 그것을 선택으로 굳히면 **다시 같은 병**(지형이 바뀌어도 안 따라감)이 된다.
if choice is not None and bool(choice) == auto_ditch:
choice = None
# 양성(both_fill)은 측구가 설 자리가 없다 — 선택보다 기하가 먼저다.
self.has_ditch = auto_ditch if (choice is None or section_mode == "both_fill") else choice
#: 그 측점에 **사용자가 정한 선택**(없으면 자동). 결과와 갈라 내보낸다.
self.ditch_choice = choice
# 측구 꼭짓점(측구측 노면 끝 기준, 바깥 방향 부호 적용).
self.ditch_points: list[tuple[float, float]] = []
edge_offset = self.left_extent if ditch_side == "left" else -self.right_extent
outward = 1.0 if ditch_side == "left" else -1.0
edge_z = self.road_z(edge_offset)
if self.has_ditch:
if ditch_type == "l_type":
# L형: 노면 끝에서 폭 W 동안 깊이 D로 내려가는 경사 바닥 + 바깥 수직벽.
# 바깥(노견 반대측) 상단은 **노견과 같은 표고**로 닫는다 — 사면 시작점이
# 측구 바닥 높이로 내려가면 안 된다(2026-08-23 사용자 지시).
width = group["l_ditch_width_m"]
depth = group["l_ditch_depth_m"]
self.ditch_points = [
(edge_offset, edge_z),
(edge_offset + outward * width, edge_z - depth),
(edge_offset + outward * width, edge_z),
]
else:
# 일반: 상단폭/저폭/깊이 사다리꼴.
top = group["ditch_top_width_m"]
bottom = min(group["ditch_bottom_width_m"], top)
depth = group["ditch_depth_m"]
inset = (top - bottom) / 2.0
self.ditch_points = [
(edge_offset, edge_z),
(edge_offset + outward * inset, edge_z - depth),
(edge_offset + outward * (inset + bottom), edge_z - depth),
(edge_offset + outward * top, edge_z),
]
def road_z(self, offset_m: float) -> float:
"""노면(노견 포함) 설계고 — 중심 계획고에서 횡단경사로 기운 단일 평면."""
return self.z_center + self.slope_per_offset * offset_m
def _slope_start(self, side: str) -> tuple[float, float]:
"""사면 시작점(오프셋 절대값 기준 거리, 표고)을 계산한다."""
if side == "left":
edge_offset, edge_z = self.left_extent, self.road_z(self.left_extent)
else:
edge_offset, edge_z = self.right_extent, self.road_z(-self.right_extent)
if side == self.ditch_side and self.ditch_points:
outer = self.ditch_points[-1]
return abs(outer[0]), outer[1]
return edge_offset, edge_z
def _rock_boundary_z(self, side: str, dist: float) -> float:
"""측·거리(절대 오프셋)에서 암반 경계선 표고 = 지반선 + 오프셋(음수=하향)."""
signed = dist if side == "left" else -dist
assert self._ground_at is not None # two_stage일 때만 호출
return self._ground_at(signed) + self._rock_offset
def cut_points(self, side: str) -> list[tuple[float, float]]:
"""절토 사면 꼭짓점 `[(거리, 표고), ...]` — 무릎과 소단이 모두 여기 들어 있다.
셈은 모듈 `common_util_cross_berm` 벌이 한다(TS 같은 것을 부른다).
소단이 없으면 종전 무릎 방식과 **같은 **이다(동치 시험으로 지킨다).
"""
if side in self._cut_points_cache:
return self._cut_points_cache[side]
start_dist, start_z = self._slope_start(side)
boundary = (lambda dist: self._rock_boundary_z(side, dist)) if self.two_stage else None
points = cut_profile_points(
start_dist,
start_z,
self.cut_ratio,
self.soil_cut_ratio,
boundary,
self.berm,
# 소단이 있으면 경계를 오갈 때마다 꺾는다 — 소단은 평탄한데 경계선은 지반을
# 따라 올라가서 되돌아 들어가는 일이 흔하다. 한 번만 꺾으면 그 구간을 암인데
# 토사 경사로 그려 절토가 조용히 커진다(2026-09-07).
multi_knee=self.berm is not None,
)
self._cut_points_cache[side] = points
return points
def _cut_slope_z(self, side: str, dist: float) -> float:
"""절토 사면선 표고(무릎·소단 반영). 지반 교차 클램프는 하지 않는다."""
return berm_elevation_at(self.cut_points(side), dist)
def fill_points(self, side: str) -> list[tuple[float, float]]:
"""성토 사면 꼭짓점 — 소단이 들어 있다. 절토와 달리 무릎은 없다."""
if side in self._fill_points_cache:
return self._fill_points_cache[side]
start_dist, start_z = self._slope_start(side)
points = fill_profile_points(start_dist, start_z, self.fill_ratio, self.berm)
self._fill_points_cache[side] = points
return points
def _fill_slope_z(self, side: str, dist: float) -> float:
"""성토 사면선 표고(소단 반영). 지반 교차 클램프는 하지 않는다."""
return berm_elevation_at(self.fill_points(side), dist)
def cut_slope_segments(self) -> list[dict[str, Any]]:
"""절토 사면을 **경사 구간별로** 쪼갠 목록.
**지금 값을 읽는 곳은 없다**(2026-09-07). 임자였던 별표2 법정 경사 검사가 폐기됐고
(암질을 횡단도에서 고르기로 사용자 확정), 저장분에도 들어간다. 소단 기하가
위에 있어 남겨 둔다 **되살릴 때는 저장분에서 읽지 말고 계산해서 .**
원래 필요했던 까닭(되살릴 그대로 유효) 소단이 서면 사면 전체를 하나로 재는
실효 경사 완만해져 **위반이 사라진 것처럼** 보인다( 1.0·간격 2 이면 설계 1:1
실효 1:1.71). 검사는 소단을 **사면 구간 자체의 경사** 봐야 한다.
· 평탄부(소단) 싣지 않는다 경사 구간이 아니고 경사비가 무한대가 된다.
· 지반과 만난 구간도 싣지 않는다 절토가 아니다.
· `material` 암반 경계 기준 `rock`/`soil`. 경계를 모르면(2단계 아님) None.
암을 다시 가르는 값은 측점의 `cut_rock_kind` 읽는다(구간에 싣지 않는다).
"""
segments: list[dict[str, Any]] = []
for side in ("left", "right"):
role = self.left_role if side == "left" else self.right_role
if role != "cut":
continue
cross = self.cut_cross_dist(side)
points = self.cut_points(side)
sign = 1.0 if side == "left" else -1.0
for index in range(1, len(points)):
start_d, start_z = points[index - 1]
end_d, end_z = points[index]
if cross is not None and start_d >= cross - 1e-9:
break # 지반과 만난 뒤는 절토가 없다
if cross is not None and end_d > cross:
# 지반과 만나는 점에서 구간을 자른다.
end_z = berm_elevation_at(points, cross)
end_d = cross
run = end_d - start_d
rise = end_z - start_z
if run <= 1e-9 or rise <= 1e-6:
continue # 길이 0·역방향은 검사 대상이 아니다
if self.berm is not None and abs(run - self.berm.width_m) < 1e-6:
# 소단(평탄부) — 폭이 딱 맞고 오름이 기울기(2°)만큼이면 그것이다.
berm_rise = math.tan(math.radians(self.berm.slope_deg)) * self.berm.width_m
if abs(rise - berm_rise) < 1e-9:
continue
# 재료는 **그 구간을 실제로 그린 경사비**로 가른다 — 경계선을 다시 재면
# 안 된다. 무릎을 지난 뒤에도 경계선은 지반을 따라 계속 오르므로, 토사
# 경사로 그린 구간이 경계 아래로 되돌아가 있는 일이 흔하다. 그것을 경계로
# 재면 「경사비는 토사인데 재료는 암」인 구간이 생긴다(2026-09-07 다른 창
# 실측: 용화 63측점에서 13구간). 그린 대로 적는 것이 맞다.
material: str | None = None
if self.two_stage and abs(self.soil_cut_ratio - self.cut_ratio) > 1e-9:
drawn = run / rise
material = (
"soil"
if abs(drawn - self.soil_cut_ratio) < abs(drawn - self.cut_ratio)
else "rock"
)
segments.append(
{
"side": side,
"ratio": round(run / rise, 4),
"rise_m": round(rise, 4),
"run_m": round(run, 4),
"start_offset_m": round(sign * start_d, 4),
"end_offset_m": round(sign * end_d, 4),
"material": material,
}
)
return segments
def cut_cross_dist(self, side: str) -> float | None:
"""절토 사면이 지반선과 처음 만나는 거리(절대 오프셋). 이후는 절토 없음(N-2-4).
지면과 1 교차하면 그다음 경사(2단계 전환 포함) 의미가 없으므로 교차점에서
절토를 종료한다. 시작(노면 )부터 사면이 지반 위면 교차거리=시작(절토 없음),
끝까지 만나면 None.
"""
if side in self._cut_cross:
return self._cut_cross[side]
result: float | None = None
if self._ground_at is not None:
start_dist, _start_z = self._slope_start(side)
step = 0.05
dist = start_dist
max_dist = start_dist + 500.0
while dist <= max_dist:
signed = dist if side == "left" else -dist
if self._cut_slope_z(side, dist) - self._ground_at(signed) >= 0:
result = dist
break
dist += step
self._cut_cross[side] = result
return result
def fill_ground_slope(self) -> float | None:
"""성토측 **자연 지반**의 평균 경사(rise/run, 무차원). 성토측이 없으면 None.
자연방토 판정에 쓴다 지반이 가파르면 부어 놓은 흙이 쌓이지 않고 흘러내린다.
구간은 노면 (사면 시작)부터 성토 사면이 지반과 처음 만나는 곳까지이며, 끝까지
만나지 못하면 10m를 본다. 양쪽이 성토면 **완만한 ** 택한다(보수적 판정).
"""
if self._ground_at is None:
return None
slopes: list[float] = []
for side in ("left", "right"):
role = self.left_role if side == "left" else self.right_role
if role != "fill":
continue
start_dist, _start_z = self._slope_start(side)
end_dist = self.fill_cross_dist(side) or (start_dist + 10.0)
run = end_dist - start_dist
if run <= 1e-6:
continue
sign = 1.0 if side == "left" else -1.0
rise = abs(self._ground_at(sign * end_dist) - self._ground_at(sign * start_dist))
slopes.append(rise / run)
return min(slopes) if slopes else None
def fill_cross_dist(self, side: str) -> float | None:
"""성토 사면이 지반선과 **처음** 만나는 거리(절대 오프셋). 이후는 성토 없음.
절토(cut_cross_dist) 같은 규칙이다. 실제 지면은 울퉁불퉁해서 성토 사면이 지반과
여러 만날 있는데, ** 교차점이 성토사면의 **이고 바깥은 손대지 않은
지반이다(2026-08-02 사용자 지시). `max(fill_line, ground)` 쓰면 지반이 다시 꺼졌을
성토 사면이 되살아나 사면이 끊겼다 이어지는 그림이 나온다.
시작(노면 )부터 사면이 지반 아래면 교차거리=시작(성토 없음), 끝까지 만나면 None.
"""
if side in self._fill_cross:
return self._fill_cross[side]
result: float | None = None
if self._ground_at is not None:
start_dist, start_z = self._slope_start(side)
step = 0.05
dist = start_dist
max_dist = start_dist + 500.0
while dist <= max_dist:
signed = dist if side == "left" else -dist
fill_line = self._fill_slope_z(side, dist)
if fill_line - self._ground_at(signed) <= 0:
result = dist
break
dist += step
self._fill_cross[side] = result
return result
def design_z(self, offset_m: float, ground_m: float) -> float:
"""offset 하나의 설계 표고(사면은 지반 교차점 이후 지반 추종)."""
side = "left" if offset_m >= 0 else "right"
extent = self.left_extent if side == "left" else self.right_extent
if abs(offset_m) <= extent + 1e-9:
return self.road_z(offset_m)
# 측구 구간: 꼭짓점 사이 선형 보간(지반 무관 강제 굴착).
if side == self.ditch_side and self.ditch_points:
inner = abs(self.ditch_points[0][0])
outer = abs(self.ditch_points[-1][0])
if inner - 1e-9 <= abs(offset_m) <= outer + 1e-9:
points = self.ditch_points
for index in range(1, len(points)):
x0, z0 = abs(points[index - 1][0]), points[index - 1][1]
x1, z1 = abs(points[index][0]), points[index][1]
if abs(offset_m) > x1 + 1e-9:
continue
span = x1 - x0
if span <= 1e-9:
return z1
ratio = (abs(offset_m) - x0) / span
return z0 + (z1 - z0) * ratio
return points[-1][1]
role = self.left_role if side == "left" else self.right_role
dist = abs(offset_m)
if role == "cut":
# 지반과 1회 교차하면 그 이후 절토는 의미 없음 → 지반 추종(N-2-4).
cross = self.cut_cross_dist(side)
if cross is not None and dist >= cross:
return ground_m
return min(self._cut_slope_z(side, dist), ground_m)
# 지반과 1회 교차하면 그 바깥은 성토가 아니라 원지반이다(절토와 같은 규칙).
cross = self.fill_cross_dist(side)
if cross is not None and dist >= cross:
return ground_m
return max(self._fill_slope_z(side, dist), ground_m)
def breakpoints(self) -> list[float]:
"""적분·설계선에 반드시 포함할 설계 꼭짓점 오프셋 목록(2단계 무릎·소단 포함)."""
points = [0.0, self.left_extent, -self.right_extent]
points.extend(offset for offset, _z in self.ditch_points)
# 절토 사면 꼭짓점(무릎·소단 모서리) — 빠뜨리면 계단이 설계선에 안 실린다.
if self.two_stage or self.berm is not None:
for side in ("left", "right"):
role = self.left_role if side == "left" else self.right_role
if role != "cut":
continue
cross = self.cut_cross_dist(side)
for offset, _z in self.cut_points(side):
if cross is not None and offset > cross + 1e-9:
break # 지반과 만난 뒤는 절토가 없다
points.append(offset if side == "left" else -offset)
# 성토 사면 소단 모서리 — 절토와 같은 까닭으로 설계선에 실어야 계단이 그려진다.
if self.berm is not None:
for side in ("left", "right"):
role = self.left_role if side == "left" else self.right_role
if role != "fill":
continue
cross = self.fill_cross_dist(side)
for offset, _z in self.fill_points(side):
if cross is not None and offset > cross + 1e-9:
break
points.append(offset if side == "left" else -offset)
# 절·성토 사면과 지반의 **첫** 교차점을 꼭짓점에 넣어 면적 절단을 정확히 한다(N-2-4).
for side in ("left", "right"):
role = self.left_role if side == "left" else self.right_role
cross = self.cut_cross_dist(side) if role == "cut" else self.fill_cross_dist(side)
if cross is not None:
points.append(cross if side == "left" else -cross)
return points
+313
View File
@@ -0,0 +1,313 @@
"""사토장(유용토운반작업장) — 용량에서 **폭을 정해** 측점마다 단면을 세운다.
여기 있나
사용자는 구간에 쌓겠다 정한다. 그런데 횡단 단면은 **** 알아야
그려진다. 그래서 구간 측점들을 한꺼번에 보고 ** 하나** 되풀이로 찾는다.
(측점마다 폭을 달리하면 실제로 쌓는 모양이 나온다 작업장은 폭이 일정하다.)
`enforce_ford_surface_drops` 같은 자리·같은 방식이다 **저장분을 쓰는 시점에**
바로잡고, 저장분과 지금 값이 다를 때만 다시 계산한다.
정하는 것과 정하는
**기울기·적치높이 기본값을 지어내지 않는다** 지식DB
`01_임도/02_상세설계/유용토운반작업장.md` §4 근거에 없다. 사용자 협의 없이
기본값을 만들지 않는다 박았다. 기울기가 비면 ** 측점의 노선 성토 기울기**
그대로 쓰고(이미 설계된 ), 높이는 **노면 높이** 정해진다.
**용량이 없으면 아무것도 세운다** 폭을 정할 근거가 없다.
**지반 샘플이 있는 데까지만 넓힌다.** 상한에서도 용량이 남으면 몫은
`unplaced_m3` 드러낸다 임의로 넓히지 않는다.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from B06_Section.B06_Section_Engine_Design import compute_cross_design
from common_util.common_util_structure_face_role import structure_face_role
#: 폭을 좁혀 가는 이분법 반복 수. TS 짝(`solveSpoilWidthM`)과 같은 값이다.
_SOLVE_STEPS = 24
#: 상한을 재려고 한 번 크게 넣어 보는 폭(m). 실제로는 지반 샘플에서 잘린다.
_MAX_PROBE_WIDTH_M = 1000.0
#: 사토장 종류 이름 — 등록부 `spoil_bank`(현행 명칭 유용토운반작업장).
SPOIL_TYPE_ID = "spoil_bank"
#: 「자동(성토 쪽)」 — 등록부 `side` 의 기본 선택지. C군 구조물과 같은 낱말이다.
_SIDE_AUTO = "자동(성토 쪽)"
_SIDE_WORDS = {"": "left", "": "right"}
def _sections_in(cross_sections: list[dict[str, Any]], start_m: float, end_m: float) -> list[dict]:
"""구간 안에 든 측점만. **새 측점을 만들지 않는다**(2026-09-09 사용자 확정 ③)."""
picked = []
for section in cross_sections:
chainage = section.get("chainage_m")
if chainage is None:
continue
value = float(chainage)
if start_m - 1e-6 <= value <= end_m + 1e-6:
picked.append(section)
return sorted(picked, key=lambda item: float(item["chainage_m"]))
def _spans(sections: list[dict[str, Any]], start_m: float, end_m: float) -> list[float]:
"""측점마다 대표 길이(m) — 앞뒤 측점과의 절반씩. 구간 끝은 경계까지만."""
spans: list[float] = []
for index, section in enumerate(sections):
chainage = float(section["chainage_m"])
left = float(sections[index - 1]["chainage_m"]) if index else max(start_m, chainage)
right = (
float(sections[index + 1]["chainage_m"])
if index + 1 < len(sections)
else min(end_m, chainage)
)
spans.append(max((chainage - left) / 2 + (right - chainage) / 2, 0.0))
return spans
def _has_spoil_value(design: dict[str, Any]) -> bool:
"""그 측점에 **사토장 값이 실제로 남아 있나**. 칸이 0 으로 있는 것은 값이 아니다."""
for key in ("spoil_fill_area_m2", "spoil_fill_width_m", "spoil_fill_capacity_m3"):
try:
if float(design.get(key) or 0.0) > 0:
return True
except (TypeError, ValueError):
continue
return bool(design.get("spoil_fill_structure_id"))
def _side_of(design: dict[str, Any], option: Any) -> str | None:
"""쌓는 쪽 — 「좌·우」면 그대로, 「자동」이면 그 측점의 **성토 쪽**."""
word = str(option or "").strip()
if word in _SIDE_WORDS:
return _SIDE_WORDS[word]
if word and word != _SIDE_AUTO:
return None
mode = str(design.get("section_mode") or "")
for korean, key in _SIDE_WORDS.items():
role, _reason = structure_face_role(mode, korean)
if role == "성토":
return key
return None
def spoil_sites(structures: list[Any]) -> list[dict[str, Any]]:
"""배치된 사토장만 골라 쓰기 좋은 모양으로. 구간·용량이 없으면 뺀다."""
sites: list[dict[str, Any]] = []
for item in structures:
type_id = getattr(item, "type_id", None) or (
item.get("type_id") if isinstance(item, dict) else None
)
if str(type_id) != SPOIL_TYPE_ID:
continue
options = getattr(item, "options", None)
if options is None and isinstance(item, dict):
options = item.get("options")
options = options or {}
start = getattr(item, "start_m", None)
end = getattr(item, "end_m", None)
if isinstance(item, dict):
start = item.get("start_m")
end = item.get("end_m")
capacity = options.get("capacity_m3")
if start is None or end is None or capacity in (None, ""):
continue
try:
capacity_value = float(capacity)
except (TypeError, ValueError):
continue
if capacity_value <= 0:
continue
sites.append(
{
"structure_id": getattr(item, "structure_id", None)
or (item.get("structure_id") if isinstance(item, dict) else None),
"start_m": min(float(start), float(end)),
"end_m": max(float(start), float(end)),
"capacity_m3": capacity_value,
"side_option": options.get("side"),
"slope_ratio_n": options.get("fill_slope_ratio"),
"extra_distance_m": options.get("extra_distance_m"),
}
)
return sites
def _volume_at(
width_m: float,
sections: list[dict[str, Any]],
spans: list[float],
sides: list[str | None],
slope_ratio_n: Any,
longitudinal: dict[str, Any],
standard: dict[str, Any] | None,
recompute,
) -> tuple[float, list[dict[str, Any] | None]]:
"""그 폭으로 쌓이는 총 부피(㎥)와 측점별 설계. 평균단면적법이 아니라 대표길이 곱이다."""
designs: list[dict[str, Any] | None] = []
total = 0.0
for section, span, side in zip(sections, spans, sides, strict=True):
if side is None or width_m <= 0:
designs.append(None)
continue
design = recompute(section, side, width_m, slope_ratio_n, longitudinal, standard)
designs.append(design)
if design:
total += float(design.get("spoil_fill_area_m2") or 0.0) * span
return total, designs
def enforce_spoil_fills(
longitudinal: dict[str, Any],
cross_sections: list[dict[str, Any]],
project_root: Path,
standard: dict[str, Any] | None = None,
) -> int:
"""사토장이 선 측점의 설계를 다시 계산한다. 바뀐 측점 수를 돌려준다.
폭은 **구간 하나에 하나** 용량에 맞춰 이분법으로 찾는다. 상한(지반 샘플이 있는
데까지)에서도 모자라면 폭으로 두고 담은 몫을 `spoil_fill_unplaced_m3` 낸다.
"""
from B05_Profile.B05_Profile_Structures_Repository import load_structures
from B06_Section.B06_Section_Engine_Design import curve_widening_args
from B06_Section.B06_Section_Router_Design import (
USER_TOUCHED_KEYS,
stored_berm,
stored_cut_slope,
)
from common_util.common_util_route_profile import design_elevation_from_longitudinal
try:
_revision, structures = load_structures(str(project_root))
except Exception: # noqa: BLE001 — 정본이 없으면 사토장도 없다
structures = []
sites = spoil_sites(structures)
def recompute(section, side, width_m, slope_ratio_n, longitudinal_data, standard_spec):
design = section.get("design")
if not isinstance(design, dict):
return None
chainage = float(section.get("chainage_m", 0.0))
try:
return compute_cross_design(
section.get("samples", []),
design_elevation_from_longitudinal(longitudinal_data, chainage),
ground_type=str(design.get("ground_type") or "soil"),
section_mode=str(design.get("section_mode") or "left_cut"),
ditch_side=design.get("ditch_side"),
ditch_type=str(design.get("ditch_type") or "standard"),
paved=bool(design.get("paved", False)),
standard=standard_spec,
rock_boundary_offset_m=design.get("rock_boundary_offset_m"),
two_stage_slope=bool(design.get("two_stage_slope", True)),
cut_slope_ratio=stored_cut_slope(design),
ditch_enabled=design.get("ditch_enabled"),
ditch_choice=design.get("ditch_choice"),
surface_drop_m=float(design.get("surface_drop_m") or 0.0),
berm=stored_berm(design),
spoil_fill={
"side": side,
"width_m": width_m,
"slope_ratio_n": slope_ratio_n,
},
**curve_widening_args(section),
)
except (ValueError, KeyError):
return None
changed = 0
# ⚠⚠ **지운 사토장이 그림·수량에 남던 자리**(2026-09-09 화면 실측으로 잡음).
# `enforce_spoil_fills` 는 **얹기만** 했으므로 구조물을 지워도 저장분의 `spoil_fill_*`
# 가 그대로 남아 **횡단도에 계속 그려지고 면적표에도 섰다.**
# ⇒ 사토장이 덮지 않는 측점에 값이 남아 있으면 **사토장 없이 다시 계산**해 지운다.
# (노선 성토도 그때 원래 값으로 돌아온다 — 사토장 몫을 빼 두었기 때문이다.)
covered: set[float] = set()
for site in sites:
for section in _sections_in(cross_sections, site["start_m"], site["end_m"]):
covered.add(float(section["chainage_m"]))
for section in cross_sections:
design = section.get("design")
if not isinstance(design, dict):
continue
chainage = section.get("chainage_m")
if chainage is None or float(chainage) in covered:
continue
# ⚠ **칸이 있는 것과 값이 있는 것은 다르다** — 설계 결과는 사토장이 없어도
# `spoil_fill_area_m2: 0.0` 을 늘 싣는다. 「값이 남아 있는」 측점만 되돌린다.
if not _has_spoil_value(design):
continue
side = _side_of(design, None)
plain = recompute(section, side, 0.0, None, longitudinal, standard) if side else None
if plain is None:
# 다시 계산할 수 없으면 **적어도 칸은 지운다** — 값이 남아 그려지는 것보다 낫다.
for key in [k for k in design if str(k).startswith("spoil_fill_")]:
design.pop(key, None)
# 노선 성토는 사토장 몫을 뺀 값이라, 다시 계산 못 하면 그 몫을 되돌려 준다.
moved = float(design.get("spoil_fill_replaced_fill_m2") or 0.0)
if moved > 0:
design["fill_area_m2"] = round(float(design.get("fill_area_m2") or 0.0) + moved, 4)
changed += 1
continue
for key in ("status", "pavement_suggested", *USER_TOUCHED_KEYS):
if design.get(key) is not None:
plain[key] = design[key]
section["design"] = plain
changed += 1
if not sites:
return changed
for site in sites:
sections = _sections_in(cross_sections, site["start_m"], site["end_m"])
if not sections:
continue
spans = _spans(sections, site["start_m"], site["end_m"])
sides = [_side_of(section.get("design") or {}, site["side_option"]) for section in sections]
ratio = site["slope_ratio_n"]
def volume(width_m: float):
return _volume_at(
width_m, sections, spans, sides, ratio, longitudinal, standard, recompute
)
# 상한 = 그 구간에서 가장 좁은 측점이 허락하는 폭. 한 측점이라도 지반 샘플이
# 모자라면 거기서 잘리므로, 넓혀도 그 측점은 안 늘어난다.
top_total, top_designs = volume(_MAX_PROBE_WIDTH_M)
limit = min(
(
float(design.get("spoil_fill_max_width_m") or 0.0)
for design in top_designs
if design
),
default=0.0,
)
if limit <= 0:
continue
total, designs = volume(limit)
width = limit
if total > site["capacity_m3"]:
low, high = 0.0, limit
for _ in range(_SOLVE_STEPS):
mid = (low + high) / 2
if volume(mid)[0] < site["capacity_m3"]:
low = mid
else:
high = mid
width = round(high, 4)
total, designs = volume(width)
unplaced = max(site["capacity_m3"] - total, 0.0)
for section, design in zip(sections, designs, strict=True):
if not design:
continue
stored = section.get("design") or {}
for key in ("status", "pavement_suggested", *USER_TOUCHED_KEYS):
if stored.get(key) is not None:
design[key] = stored[key]
# 구간 전체 값도 측점마다 실어 둔다 — 화면 말풍선·수량이 되짚을 수 있게.
design["spoil_fill_capacity_m3"] = round(site["capacity_m3"], 4)
design["spoil_fill_placed_m3"] = round(total, 4)
design["spoil_fill_unplaced_m3"] = round(unplaced, 4)
design["spoil_fill_structure_id"] = site["structure_id"]
design["spoil_fill_extra_distance_m"] = site["extra_distance_m"]
section["design"] = design
changed += 1
return changed
@@ -73,6 +73,9 @@ def load_wall_structures(project_root: Path) -> list[dict[str, Any]]:
# C군 폼에는 설치 측 칸이 없다 — 비워 두면 화면이 **성토가 나는 쪽**으로
# 세운다(`computeRevetmentLayout`). 사용자가 정하고 싶어지면 그때 칸을 낸다.
"side": options.get("side"),
# 기초 축(기초유/기초버림) — 터파기 그림·물량이 같이 쓴다.
# 비어 있으면 화면이 터파기를 안 그린다(근거 없음).
"foundation": options.get("foundation"),
"tiers": options.get("tiers"),
"lift_m": options.get("lift_m"),
"shift_m": options.get("shift_m"),
+1
View File
@@ -641,6 +641,7 @@ async def compute_cross_section_design(
# 측점별 암 절토 경사 — 요청값이 없으면 저장분에서 잇는다(2026-09-07).
cut_slope_ratio=request.cut_slope_ratio or stored_cut_slope(stored_design or {}),
ditch_enabled=request.ditch_enabled,
ditch_choice=request.ditch_choice,
surface_drop_m=ford_drop_at(request.chainage_m, ford_surface_drops(project_root)),
berm=stored_berm(stored_design or {}),
**curve_widening_args(cross_record),
+7 -1
View File
@@ -189,9 +189,15 @@ async def save_sections(
)
stored_path = await get_project_storage_relative_path(connection, project_id)
known = await get_cross_section_chainages(connection, route_id)
# 행은 있는데 **설계가 빈** 측점도 채운다(2026-09-09). 「빈 설계」란 `design` 이
# 없거나 **`ground_type` 이 없는 것**이다 — 재생성이 행을 다시 쓰면 면적 4키만
# 남는데, 그 상태를 화면·B08 이 「설계 없음」으로 읽어 토적표가 통째로 0 이 됐다.
# 예전에는 체인이 뒤이어 부르는 [확정]이 이 자리를 채워 가려져 있었다.
# ⚠ **값이 있는 행은 건드리지 않는다** — 사용자 조작값·이월분이 거기 있다.
missing = await get_cross_sections_missing_design_chainages(connection, route_id)
project_root = Path(resolve_stored_project_path(stored_path))
rowless = await asyncio.to_thread(
rowless = missing + await asyncio.to_thread(
_rowless_station_chainages,
project_root,
str(existing["longitudinal_file_path"]),
+3
View File
@@ -198,6 +198,7 @@ def enforce_pavement_ranges(
two_stage_slope=bool(design.get("two_stage_slope", True)),
cut_slope_ratio=stored_cut_slope(design),
ditch_enabled=design.get("ditch_enabled"),
ditch_choice=design.get("ditch_choice"),
surface_drop_m=ford_drop_at(chainage, ford_drops),
berm=stored_berm(design),
**curve_widening_args(section),
@@ -251,6 +252,7 @@ def enforce_ford_surface_drops(
two_stage_slope=bool(design.get("two_stage_slope", True)),
cut_slope_ratio=stored_cut_slope(design),
ditch_enabled=design.get("ditch_enabled"),
ditch_choice=design.get("ditch_choice"),
surface_drop_m=wanted,
berm=stored_berm(design),
**curve_widening_args(section),
@@ -446,6 +448,7 @@ def recompute_designs_for_alignment(
else stored_cut_slope(stored)
),
ditch_enabled=stored.get("ditch_enabled"),
ditch_choice=stored.get("ditch_choice"),
surface_drop_m=ford_drop_at(chainage, ford_drops),
berm=session_berms.get(key) or stored_berm(stored),
**curve_widening_args(section),
+12 -2
View File
@@ -22,7 +22,11 @@ from uuid import UUID
from fastapi import APIRouter, Body
from fastapi.responses import JSONResponse
from B06_Section.B06_Section_Server_Calc_Prebuild import BUNDLE, _mass_haul_context
from B06_Section.B06_Section_Server_Calc_Prebuild import (
BUNDLE,
_mass_haul_context,
haul_inputs_for,
)
from common_util.common_util_node_bundle import run_bundle_json
logger = logging.getLogger(__name__)
@@ -56,12 +60,18 @@ async def compute_haul_plan(
status_code=400,
content={"status": "error", "message": "측점 수가 너무 많습니다."},
)
# 구조물 몫(공제·잔토)을 **넘겨야** 사토가 줄고 는다 — 인자 없이 부르면 늘 `None` 이라
# 통로만 있고 값이 안 흐른다(2026-09-09 실측으로 드러난 자리).
haul_inputs = await haul_inputs_for(project_id)
try:
output = await asyncio.to_thread(
run_bundle_json,
BUNDLE,
_NPM_SCRIPT,
{"haul_plan_for": result, "context": _mass_haul_context()},
{
"haul_plan_for": result,
"context": _mass_haul_context(haul_inputs),
},
)
except Exception:
logger.exception("유토 배분 계산 실패: project_id=%s route_id=%s", project_id, route_id)
+247
View File
@@ -0,0 +1,247 @@
"""구조물 측점이 빠진 관·시설을 **알리고, 눌러서 만든다** (계획서 3-14 ㉯).
무엇이 문제였나
측점을 만드는 자리는 **B05 노선 [확정] 곳뿐**이다. 관을 저장하는
`PUT /drainage/pipe-points` 파일과 유역만 쓰고 측점을 다시 만들지 않는다.
**관을 나중에 놓거나 옮기면 측점이 생긴다.** 관은 횡단도에도 서고
수량·금액에서 통째로 빠지는데 **아무 말도 나온다**(실측: 배수관 ).
방식인가 ( 갈래 )
저장 바로 만들기 엔드포인트에 노선·지표면 인자가 없어 끌어와야
**알리고 [측점 만들기] 단추** 누를 때만 돌아 비용이 적고 ** 값이 없는지가 보임**
그대로 두기 조용히 빠지는 것이 문제라 적어도 알림은 있어야
**지어내지 않는 ** 지표 샘플링 조건(어느 DTM·어느 방법) 없으면 만들지 않는다.
조건이 다르면 측점만 다른 지표에서 뽑혀 ** 측점과 지반고가 어긋난다.** 조건은
노선 확정 남긴 `B06_Section/sampling.json` 에서 읽고, 없으면 사유를 내고 막는다.
"""
from __future__ import annotations
import asyncio
import logging
from pathlib import Path
from typing import Any
from uuid import UUID
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_Profile.B05_Profile_Repository import get_latest_route, get_surface_crs_epsg
from B05_Profile.B05_Profile_Router_Confirm import load_sampling_snapshot
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import run_with_connection
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B06 Section"])
#: 「그 자리에 측점이 있다」고 볼 거리(m).
#: ⚠⚠ **0.5m 다 — 0.05m 가 아니다**(2026-09-09 실측으로 뒤집힌 자리).
#: 측점을 만들 때 **정수 미터가 같은 격자 측점이 있으면 그리로 스냅**한다
#: (`B05_Profile_Engine_Sections_Core` 의 파일명 가드). 그래서 관 440.241 은 측점 440.0
#: 위에 서고, 그 측점은 **구조물 이름표까지 달고 있다**(`structure`).
#: 0.05m 로 보면 그런 자리를 「측점 없음」으로 잘못 세어 **있는 측점을 또 만들라고 한다.**
#: 스냅 폭이 「정수 미터 반올림」이므로 최대 어긋남은 0.5m 다.
STATION_MATCH_TOLERANCE_M = 0.5
SNAPSHOT_MISSING = (
"지표 샘플링 조건을 찾을 수 없어 측점을 만들 수 없음 — 1단계(지표 확정)를 마친 뒤"
" 다시 눌러야 함. ⚠ 조건을 지어내면 그 측점만 다른 지표에서 뽑혀 옆 측점과 지반고가 어긋남"
)
async def _sampling_conditions(project_id: UUID, project_root: Path) -> dict[str, Any] | None:
"""이 프로젝트가 쓰는 지표 샘플링 조건. 둘 다 **기록된 값**이고 지어내지 않는다.
노선 [확정] 남긴 `B06_Section/sampling.json` **그때 실제로 조건**이라 1순위.
없으면 1단계(지표 확정) 저장값 B06 화면 `context` 쓰는 값이라 같은 조건이다.
프로젝트는 없으므로 길이 없으면 단추가 영영 돈다.
"""
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
snapshot = load_sampling_snapshot(project_root)
if snapshot is not None:
return snapshot
params = await run_with_connection(get_surface_confirmation_params, str(project_id))
if not params or not params.get("source_filter") or not params.get("method"):
return None
return {
"filter_key": params["source_filter"],
"method": params["method"],
"smooth": bool(params.get("smooth")),
"surface_model_id": None,
"source": "stage1",
}
def _missing_marks(project_root: Path, route_data_path: str) -> list[dict[str, Any]]:
"""구조물 측점 가운데 **종단 정본에 행이 없는 것**만. 없으면 빈 목록."""
from B05_Profile.B05_Profile_Engine_Sections import (
_load_pipe_points,
_load_route_polyline,
resolve_extra_stations,
)
polyline = _load_route_polyline(project_root, route_data_path)
pipes = _load_pipe_points(project_root, polyline)
extras = resolve_extra_stations(project_root, pipes)
if not extras:
return []
existing = _station_chainages(project_root)
missing = []
for chainage, label in extras:
value = float(chainage)
if any(abs(value - other) <= STATION_MATCH_TOLERANCE_M for other in existing):
continue
missing.append({"chainage_m": round(value, 3), "label": label})
return sorted(missing, key=lambda item: item["chainage_m"])
def _station_chainages(project_root: Path) -> list[float]:
"""종단 정본에 실제로 서 있는 측점 누가거리. 파일이 없으면 빈 목록."""
import json
folder = project_root / "B06_Section" / "longitudinal"
values: list[float] = []
for path in sorted(folder.glob("*.json")):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
continue
for station in data.get("stations") or []:
chainage = station.get("chainage_m")
if isinstance(chainage, (int, float)):
values.append(float(chainage))
return values
async def _project_paths(project_id: UUID) -> tuple[Path, dict[str, Any]] | None:
async def _load(connection):
stored = await get_project_storage_relative_path(connection, project_id)
route = await get_latest_route(connection, project_id)
return stored, route
stored, route = await run_with_connection(_load)
if not stored or not route:
return None
return Path(resolve_stored_project_path(stored)), route
@router.get("/{project_id}/section/missing-stations")
async def get_missing_stations(project_id: UUID) -> JSONResponse:
"""측점이 없는 구조물 목록 — 화면이 「측점 없는 관 N개」를 띄우는 데 쓴다."""
try:
paths = await _project_paths(project_id)
if paths is None:
return JSONResponse(content={"status": "success", "missing": [], "can_create": False})
project_root, route = paths
missing = await asyncio.to_thread(
_missing_marks, project_root, str(route["route_data_path"])
)
snapshot = await _sampling_conditions(project_id, project_root)
except Exception:
logger.exception("B06 측점 점검 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "측점을 점검하지 못했습니다."},
)
return JSONResponse(
content={
"status": "success",
"missing": missing,
"can_create": bool(missing) and snapshot is not None,
"reason": "" if snapshot is not None else SNAPSHOT_MISSING,
}
)
@router.post("/{project_id}/section/missing-stations")
async def create_missing_stations(project_id: UUID) -> JSONResponse:
"""빠진 구조물 측점을 **노선 확정 때와 같은 조건으로** 만들어 종단 정본에 병합한다.
구조물 측점 전체를 다시 뜬다 종단 병합이 비정규 측점을 **통째로 교체**하므로
빠진 것만 넘기면 이미 있던 구조물 측점이 지워진다.
"""
from B05_Profile.B05_Profile_Engine_Sections import (
_load_pipe_points,
_load_route_polyline,
generate_irregular_sections,
resolve_extra_stations,
)
from B05_Profile.B05_Profile_Router_Confirm import (
_merge_irregular_into_longitudinal,
_section_options_from_stored,
)
from B06_Section.B06_Section_Repository import (
get_latest_section_options,
get_longitudinal_section,
)
try:
paths = await _project_paths(project_id)
if paths is None:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "확정된 노선이 없습니다."},
)
project_root, route = paths
snapshot = await _sampling_conditions(project_id, project_root)
if snapshot is None:
return JSONResponse(
status_code=409,
content={"status": "error", "message": SNAPSHOT_MISSING},
)
missing = await asyncio.to_thread(
_missing_marks, project_root, str(route["route_data_path"])
)
if not missing:
return JSONResponse(content={"status": "success", "created": 0, "missing": []})
async def _load(connection):
options = await get_latest_section_options(connection, project_id)
crs_epsg = await get_surface_crs_epsg(
connection, project_id, snapshot.get("surface_model_id")
)
longitudinal = await get_longitudinal_section(connection, project_id, route["id"])
return options, crs_epsg, longitudinal
stored_options, crs_epsg, longitudinal = await run_with_connection(_load)
def _regenerate() -> int:
polyline = _load_route_polyline(project_root, str(route["route_data_path"]))
pipes = _load_pipe_points(project_root, polyline)
extras = resolve_extra_stations(project_root, pipes)
stations = generate_irregular_sections(
project_root,
str(route["route_data_path"]),
str(snapshot["filter_key"]),
str(snapshot["method"]),
bool(snapshot.get("smooth")),
extra_stations=extras,
options=_section_options_from_stored(stored_options),
crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None,
)
if stations and longitudinal:
_merge_irregular_into_longitudinal(
project_root, str(longitudinal["longitudinal_file_path"]), stations
)
return len(stations)
made = await asyncio.to_thread(_regenerate)
except Exception:
logger.exception("B06 측점 만들기 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "측점을 만들지 못했습니다."},
)
return JSONResponse(
content={
"status": "success",
# 새로 선 것만 세어 낸다 — 다시 뜬 총수(`made`)와 다르다.
"created": len(missing),
"regenerated": made,
"missing": missing,
}
)
+3 -1
View File
@@ -55,8 +55,10 @@ class CrossDesignRequest(BaseModel):
# 이 측점만 쓰는 암 절토 경사비(1:n 의 n) — 카드에서 넣은 값(2026-09-07 사용자 지시).
# None 이면 표준 횡단면 설정값을 쓴다.
cut_slope_ratio: float | None = Field(default=None, gt=0)
# 측구 생성 여부. None=자동 판정(측구측 절토면만 생성), True/False=수동 override.
# 옛 이름 — **결과값**이 그대로 실려 오던 자리(호환). 자동값과 다를 때만 선택으로 산다.
ditch_enabled: bool | None = None
# 측구를 둘지 **사용자가 정한 선택**. None = 자동 판정(2026-09-09 갈라냄).
ditch_choice: bool | None = None
# B06 설정 패널 편집값(STANDARD_CROSS_SECTION 형태). 요청값 → config 기본값 순.
standard_cross_section: dict[str, Any] | None = None
+4 -1
View File
@@ -52,7 +52,10 @@ async function withDraftWalls(
projectId: string,
): Promise<SectionDetailResponse> {
const drafts = readPendingStructures(projectId);
if (!drafts) return withStructureAreas(detail);
// ⚠ **빈 목록도 「초안 없음」이다**(2026-09-09). 예전에는 `[]` 가 「초안이 있는데 벽이
// 하나도 없다」로 읽혀 **아래에서 서버 저장분을 통째로 지웠다** — 구조물을 놓아도
// 횡단도에 아무것도 안 보이던 결함의 원인이다(창 둘에서 같은 증상, 실측 확인).
if (!drafts || !drafts.length) return withStructureAreas(detail);
const types = await fetchStructureTypes().catch(() => []);
const names = new Map(
types
+36 -2
View File
@@ -37,6 +37,23 @@ interface ServerCalcInput {
earthwork_conversion?: Parameters<typeof computeMassHaul>[1];
natural_spoil_min_ground_slope?: number | null;
haul_equipment_limits?: Parameters<typeof computeHaulPlan>[1];
/** 채집석 공제(㎥, 양수) — B08 이 낸다. `null`/없음은 「아직 안 옴」이다. */
collected_stone_deduction_m3?: number | null;
/** 갈래별 채집석(㎥) — 벽 입적(자연 축)이라 곡선 쪽이 ×C 해 다짐 축에 맞춘다. */
collected_stone_by_ground_m3?: Record<string, number> | null;
/** 갈래를 못 가른 채집석(㎥) — 계수가 없어 환산하지 않는다. */
collected_stone_ground_unknown_m3?: number | null;
/** 구조물 터파기 잔토(㎥, 양수) — B08 이 낸다. 사토에 **더한다**. */
structure_spoil_m3?: number | null;
/** 측점별 잔토 — 오면 **그 자리**에 얹는다(운반거리가 맞다). */
structure_spoil_points?: Array<{
chainage_m: number;
spoil_m3: number;
/** 그 터파기의 토질 — B08 이 이미 판정한 값(품셈 9-13). `null` 이면 「지반 모름」. */
ground_type?: string | null;
ground_label?: string | null;
ground?: string | null;
}> | null;
};
}
@@ -52,7 +69,15 @@ const input = JSON.parse(readFileSync(inputPath, "utf8")) as ServerCalcInput;
if (input.haul_plan_for) {
// **화면이 쓰는 꼴 그대로** 내보낸다(직렬화 형태 `haulPlanPayload` 가 아니다) — 그래야
// 그리기 코드가 손대지 않고 그대로 받는다. 전부 숫자·문자열이라 JSON 으로 오간다.
const plan = computeHaulPlan(input.haul_plan_for, input.context?.haul_equipment_limits);
const plan = computeHaulPlan(input.haul_plan_for, input.context?.haul_equipment_limits, {
collected_stone_deduction_m3: input.context?.collected_stone_deduction_m3 ?? null,
collected_stone_by_ground_m3: input.context?.collected_stone_by_ground_m3 ?? null,
collected_stone_ground_unknown_m3: input.context?.collected_stone_ground_unknown_m3 ?? null,
structure_spoil_m3: input.context?.structure_spoil_m3 ?? null,
structure_spoil_points: input.context?.structure_spoil_points ?? null,
// 잔토는 자연상태로 오고 곡선은 다짐상태다 — 담기 전에 ×C 하는 데 쓴다.
conversion: input.context?.earthwork_conversion ?? null,
});
writeFileSync(outputPath, JSON.stringify({ haul_plan: plan ?? null }));
process.exit(0);
}
@@ -73,7 +98,16 @@ const result = conversion
)
: null;
// 배분은 **서버만** 만든다 — 그래야 그 코드가 브라우저 번들에서 빠진다(2026-09-06).
const plan = result ? computeHaulPlan(result, input.context?.haul_equipment_limits) : null;
const plan = result
? computeHaulPlan(result, input.context?.haul_equipment_limits, {
collected_stone_deduction_m3: input.context?.collected_stone_deduction_m3 ?? null,
collected_stone_by_ground_m3: input.context?.collected_stone_by_ground_m3 ?? null,
collected_stone_ground_unknown_m3: input.context?.collected_stone_ground_unknown_m3 ?? null,
structure_spoil_m3: input.context?.structure_spoil_m3 ?? null,
structure_spoil_points: input.context?.structure_spoil_points ?? null,
conversion: conversion ?? null,
})
: null;
const massHaul = result
? massHaulPayload(result, plan ? { haul_plan: haulPlanPayload(plan) } : null)
: null;
@@ -63,8 +63,35 @@ _AREA_KEYS = (
)
def _mass_haul_context() -> dict[str, Any]:
"""유토곡선 계산에 필요한 값 — 화면이 `sections/context`로 받는 것과 같은 상수다."""
async def haul_inputs_for(project_id: Any) -> dict[str, Any]:
"""B08 이 낸 **구조물 몫**(채집석 공제·구조물 잔토)을 받아 온다.
**여기서 다시 세지 않는다** B08 전개에서 나오는 것이라 이쪽이 세면
같은 계산이 벌이 된다(CLAUDE.md 5). 읽으면 값으로 두고 **0 으로 눅이지 않는다**.
"""
try:
from B08_Quantity.B08_Quantity_Router_Material import project_haul_inputs
data = await project_haul_inputs(project_id)
return data if isinstance(data, dict) else {}
except Exception:
logger.exception("구조물 몫(공제·잔토) 조회 실패 — 값 없이 진행: project_id=%s", project_id)
return {}
def _mass_haul_context(haul_inputs: dict[str, Any] | None = None) -> dict[str, Any]:
"""유토곡선 계산에 필요한 값 — 화면이 `sections/context`로 받는 것과 같은 상수다.
채집석 공제(`collected_stone_deduction_m3`) 상수가 아니라 **B08 내는 **이다.
`None` 아직 이고 `0` 공제 없음이라 **서로 다르다** 값이 것을
공제 0 으로 읽으면 조용히 넘어간다(2026-09-09 합의).
채집석 공제는 사토에서 번만 뺀다.
B08 소요량(collected_stone_deduction_m3, 양수) 내기만 하고 공제하지 않으며,
빼는 자리는 유토곡선의 사토뿐이다
실어 내는 (spoil_m3 natural_spoil_m3)에서 먼저 빼고 모자라면 자연방토에서 뺀다.
"""
inputs = haul_inputs or {}
return {
"earthwork_conversion": EARTHWORK_CONVERSION_FACTORS,
"natural_spoil_min_ground_slope": NATURAL_SPOIL_MIN_GROUND_SLOPE,
@@ -72,6 +99,20 @@ def _mass_haul_context() -> dict[str, Any]:
{"key": key, "max_distance_m": limit}
for key, limit in EARTHWORK_HAUL_EQUIPMENT_LIMITS_M
],
# ⚠ B08 이 아직 이 값을 내지 않는다(2026-09-09) — 그때까지 `None`(아직 안 옴)이다.
# 값을 내기 시작하면 여기에 실어 주기만 하면 통로가 이어진다.
"collected_stone_deduction_m3": inputs.get("collected_stone_deduction_m3"),
# 갈래별 채집석(2026-09-09 세 창 확정) — **벽 입적**이라 자연 축으로 보고 곡선 쪽에서
# ×C 해 다짐 축에 맞춰 뺀다. 갈래를 못 가른 몫은 계수가 없어 **환산하지 않는다**.
"collected_stone_by_ground_m3": inputs.get("collected_stone_by_ground_m3") or {},
"collected_stone_ground_unknown_m3": inputs.get("collected_stone_ground_unknown_m3"),
# 구조물 터파기 잔토(㎥, 양수) — **사토에 더한다**(공제는 빼고 이것은 더한다).
# 구조물 잔토는 사토에 한 번만 더한다.
# B08 은 소요량(structure_spoil_m3, ㎥ 양수)을 내기만 하고,
# 더하는 자리는 유토곡선의 사토뿐이다.
"structure_spoil_m3": inputs.get("structure_spoil_m3"),
# 측점별 잔토 — 오면 이쪽이 이긴다(구조물이 선 자리 잔량에 얹어 운반거리를 맞춘다).
"structure_spoil_points": inputs.get("structure_spoil_points"),
}
@@ -86,6 +127,7 @@ def _enforce_stored_designs(
예전에는 상세를 **읽을 때마다** 돌려 화면이 때만 맞았다(저장분은 낡은 채로).
2026-09-06 사용자 확정대로 읽기는 영구저장소에서 가져오기만이므로 이쪽으로 옮겼다.
"""
from B06_Section.B06_Section_Engine_SpoilFill import enforce_spoil_fills
from B06_Section.B06_Section_Router_Design import (
enforce_ford_surface_drops,
enforce_pavement_ranges,
@@ -93,6 +135,9 @@ def _enforce_stored_designs(
enforce_pavement_ranges(longitudinal, sections, project_root, standard)
enforce_ford_surface_drops(longitudinal, sections, project_root, standard)
# ⚠ 사토장은 **맨 뒤**다 — 앞의 두 보정이 설계를 다시 계산하면서 사토장 칸을 지운다.
# 맨 뒤에 두면 그 결과 위에 사토장 단면이 얹힌다(2026-09-09).
enforce_spoil_fills(longitudinal, sections, project_root, standard)
async def recompute_server_side(project_id: UUID | str, route_id: int) -> int:
@@ -113,10 +158,13 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
# 상세 만들기(파일 읽기 위주)와 DB 두 건은 서로 기다릴 이유가 없다 — 같이 보낸다.
# 원격 DB 라 순차로 내면 왕복이 그대로 더해진다(질의 하나 약 12ms, 2026-09-06 실측).
pool = get_db_pool()
response, stored_path, longitudinal_row = await asyncio.gather(
# 구조물 몫(채집석 공제·구조물 잔토)도 함께 받아 온다 — **B08 이 낸 값**이고, 안 넘기면
# 통로만 있고 값이 안 흐른다(2026-09-09 실측: 공제가 늘 `None` 이라 사토가 안 줄었다).
response, stored_path, longitudinal_row, haul_inputs = await asyncio.gather(
get_section_detail(project_uuid, route_id),
run_with_connection(get_project_storage_relative_path, project_uuid),
run_with_connection(get_longitudinal_section, project_uuid, route_id),
haul_inputs_for(project_uuid),
)
marks.append(("종횡단 상세+DB 조회(병렬)", time.perf_counter()))
payload = getattr(response, "model_dump", None)
@@ -146,7 +194,10 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
run_bundle_json,
BUNDLE,
_NPM_SCRIPT,
{"detail": detail, "context": _mass_haul_context()},
{
"detail": detail,
"context": _mass_haul_context(haul_inputs),
},
)
marks.append(("Node 번들(면적·유토곡선)", time.perf_counter()))
if not isinstance(output, dict):
+33 -2
View File
@@ -61,6 +61,27 @@ const inletStructure: InletStructureControl = {
resetAdjust: () => undefined,
};
/**
* ** ** . `null`.
*
* ( ).
* ** ** .
*/
function pipeOwnerChainage(
section: CrossSection,
sections: readonly CrossSection[],
): number | null {
const target = section.culvert?.chainage_m;
if (typeof target !== "number") return null;
let best: number | null = null;
for (const item of sections) {
if (best === null || Math.abs(item.chainage_m - target) < Math.abs(best - target)) {
best = item.chainage_m;
}
}
return best;
}
/** 한 측점의 구조물 기하 한 벌 — 설계선 트림과 구조물 그리기가 같은 결과를 나눠 쓴다. */
export function computeStoredLayouts(section: CrossSection, sections: readonly CrossSection[]) {
const design = section.design;
@@ -136,10 +157,20 @@ function areaRowOf(
// ⚠ **관을 가진 측점(소유)에만 싣는다.** 옆 측점도 그 관 구간에 걸리면 레이아웃을 만들지만
// (`culvertLinkFor` — 3D·카드가 이어 그리려고), 그 자리에 길이를 실으면 **같은 관을 두 번**
// 세게 된다. 실측에서 관 9개에 값이 10곳 실렸던 자리다.
//
// ⚠⚠ **관 자리와 측점 자리는 최대 0.5m 어긋난다 — 그것이 설계다**(2026-09-09 실측).
// 측점을 만들 때 **정수 미터가 같은 격자 측점이 있으면 그리로 스냅**한다
// (`B05_Profile_Engine_Sections_Core` — 횡단 파일명이 정수 미터라 두 측점이 한 파일을
// 덮어쓰는 것을 막는 가드). 그래서 관 440.241 은 **측점 440.0** 위에 선다.
// ⇒ 0.02m 로 주인을 가리면 **그런 관은 주인이 없어** 길이가 아무 데도 안 실리고,
// B08 이 「연장 없음」으로 막아 **금액이 통째로 빠진다**(실측: 배수관 넷).
// ⇒ **가장 가까운 측점 하나**를 주인으로 본다. 거리로 자르지 않으므로 스냅 폭이
// 바뀌어도 따라가고, 하나만 고르므로 두 번 세지도 않는다.
const ownerChainage = pipeOwnerChainage(section, sections);
const pipeOwner =
!!section.culvert &&
(typeof section.culvert.chainage_m !== "number" ||
Math.abs(section.culvert.chainage_m - section.chainage_m) <= CHAINAGE_TOLERANCE_M);
(ownerChainage === null ||
Math.abs(ownerChainage - section.chainage_m) <= CHAINAGE_TOLERANCE_M);
const pipeLengthM = pipeOwner ? layouts.culvert?.pipe?.lengthM : undefined;
const pipeRow: Record<string, number> | null =
typeof pipeLengthM === "number" && pipeLengthM > 0
+15
View File
@@ -375,6 +375,21 @@ export function buildAreaReadout(
],
},
];
// 사토장이 선 측점만 한 줄 더 — **성토와 합치지 않는다**(확정 ㉠). 없는 측점에 빈 줄을
// 세우면 어느 측점에 사토장이 있는지 표에서 안 보인다.
const spoilArea = Number(design.spoil_fill_area_m2 ?? 0);
if (spoilArea > 0) {
rows.push({
label: L("B06_Design_SpoilFill_Area"),
variant: "spoil-fill",
cells: [
{ key: null, value: null, variant: "spoil-fill" },
{ key: null, value: null, variant: "spoil-fill" },
{ key: null, value: null, variant: "spoil-fill" },
{ key: null, value: spoilArea, variant: "spoil-fill" },
],
});
}
for (const row of rows) {
const tr = document.createElement("tr");
const th = document.createElement("th");
@@ -23,6 +23,11 @@ import type {
PipeEnd,
WallLayout,
} from "./B06_Section_UI_Cross_Culvert_Geom";
import {
appendPipeTrench,
appendWallTrench,
foundationChoice,
} from "./B06_Section_UI_Cross_Excavation";
import { appendWallHatch } from "./B06_Section_UI_Cross_Wall_Hatch";
// 기하 계산 진입점과 공개 상수·타입은 여기서 재수출한다 — B05(최소 토피)와 횡단 뷰가
@@ -162,6 +167,12 @@ export function appendCulvertOverlay(
const revetShapes = new Map<RevetKey, SVGPolygonElement>();
/** 집수정 부재 도형 — 유입 선택 강조에 쓴다(2026-08-22 사용자 ③). */
const basinShapes: SVGPolygonElement[] = [];
// 터파기는 구조물보다 **먼저** 그린다 — 파선이 벽 밑에 깔려야 도면처럼 보인다.
// 저장 제원 `foundation`(기초유/기초버림)이 비어 있으면 그리지 않는다 — 근거가 없다.
const wallFoundation = foundationChoice(culvert.foundation);
for (const { wall } of wallsInOrder) {
appendWallTrench(layer, wall, wallFoundation, x, toDisplayY);
}
for (const { wall, key: wallKey } of wallsInOrder) {
// 합성 단면(하부 사다리꼴 + 상부 평행사변형) — 상단 배면이 사면선 접점(사용자 ①·②).
const revetShape = polygon(
@@ -272,6 +283,26 @@ export function appendCulvertOverlay(
const inletFinal = pushOut(pipeCorners.inlet, -1);
const outletFinal = pushOut(pipeCorners.outlet, 1);
// 관 터파기 — 표값 폭으로 관 위 지반선에서 관 바닥까지 수직(KCS 44 40 10 그림 3.2-1).
if (!hidePipe) {
const invert = Math.min(
pipeCorners.inlet.bottom.elevation,
pipeCorners.outlet.bottom.elevation,
);
const centerOffset = (pipeCorners.inlet.bottom.offset + pipeCorners.outlet.bottom.offset) / 2;
appendPipeTrench(
layer,
{
centerOffsetM: centerOffset,
invertM: invert,
topM: invert + diameter + culvert.min_cover_m,
diameterMm: Math.round(diameter * 1000),
},
x,
toDisplayY,
);
}
const kindLabel = culvert.pipe_kind ? `${culvert.pipe_kind} ` : "";
const wallThickness = pipeWallThicknessM(culvert.pipe_kind, diameter);
const tip =
@@ -256,6 +256,27 @@ export function wallStandsAt(owner: CrossSection, section: CrossSection, key: st
return spanCovers(revetSpanOfSpec(side), deltaM);
}
/**
* ** **.
* (D경로) .
*
* (2026-09-09)
* . ** ** ,
* · D경로가 .
* **** .
*/
export function culvertWallsStandAt(owner: CrossSection, section: CrossSection): boolean {
const keys = ["inlet", "outlet"];
const counts = owner.design?.extra_wall_counts ?? {};
for (const [side, count] of Object.entries(counts)) {
const total = Math.max(Math.trunc(Number(count) || 0), 0);
for (let index = 0; index < total; index += 1) {
keys.push(side === "basin" ? `bextra${index}` : `extra${index}`);
}
}
return keys.some((key) => wallStandsAt(owner, section, key));
}
export function culvertLinkFor(
section: CrossSection,
sections: readonly CrossSection[],
+8 -4
View File
@@ -113,8 +113,9 @@ export interface CrossDesignChange {
paved: boolean;
/** 암 지반 2단계 경사(암반 경계 아래=암, 위=토사) 적용 여부. 기본 true, 토글로 해제. */
two_stage_slope: boolean;
/** 측구 생성 여부. null=자동 판정, true/false=수동 override. */
ditch_enabled: boolean | null;
/** ** **. `null` = .
* (`design.ditch_enabled` = ) ** **(2026-09-09 ). */
ditch_choice: boolean | null;
}
/**
@@ -337,7 +338,9 @@ export function buildDesignControls(
paved: design?.paved ?? false,
// 암 design일 때만 저장값을 신뢰(토사는 two_stage=false echo가 무의미) — 암 전환 시 기본 복합경사(4번).
twoStage: design && isRock(design.ground_type) ? (design.two_stage_slope ?? true) : true,
ditchEnabled: design?.ditch_enabled ?? null,
// ⚠ **결과가 아니라 선택을 읽는다.** 결과를 읽으면 한 번 저장된 뒤로
// 자동 판정이 영영 안 돈다(2026-09-09에 갈라낸 자리).
ditchEnabled: design?.ditch_choice ?? null,
};
const bar = document.createElement("div");
bar.className = "b06-design";
@@ -358,7 +361,7 @@ export function buildDesignControls(
ditch_type: state.ditchType,
paved: state.paved,
two_stage_slope: state.twoStage,
ditch_enabled: state.ditchEnabled,
ditch_choice: state.ditchEnabled,
});
};
@@ -386,6 +389,7 @@ export function buildDesignControls(
const rockCut = isRock(state.ground) && state.mode !== "both_fill";
const hasDitch = state.mode !== "both_fill";
// 측구 생성 여부(자동 판정 or 사용자 override) — 측구형식은 측구가 있을 때만 의미 있다.
// 화면 표시는 **결과**를 보인다 — 선택이 없으면 자동으로 선 결과가 답이다.
const ditchOn = state.ditchEnabled ?? design?.ditch_enabled ?? true;
// 경사 방향(좌/우): 양절·양성에서 활성. 항상 인라인 노출(오버플로 대상 아님). 라벨 삭제(E-7).
@@ -0,0 +1,119 @@
/* =============================================================================
* B06_Section_UI_Cross_Excavation.ts
* ** ** · (2026-09-09 ).
*
* .
* ** ** .
*
* ** ** `common_util_excavation`
* ( ). **** .
* : 법정 (KCS 44 40 10 3.2-1)
* : 법정 ( xls)
* ** .** .
* ========================================================================== */
import {
BASIS_PIPE,
BASIS_WALL,
WALL_BLINDING_DEPTH_M,
WALL_BLINDING_WIDTH_M,
WALL_FOUNDATION_DEPTH_M,
WALL_FOUNDATION_WIDTH_M,
WALL_TRENCH_CLEARANCE_M,
pipeTrenchWidthM,
} from "@util/common_util_excavation";
import type { WallLayout } from "./B06_Section_UI_Cross_Culvert_Types";
const SVG_NS = "http://www.w3.org/2000/svg";
/** 「기초유」인가 — 저장 제원 `foundation` 의 값. 비어 있으면 **그리지 않는다**(근거 없음). */
export function foundationChoice(value: unknown): "기초유" | "기초버림" | null {
const text = typeof value === "string" ? value.trim() : "";
if (text === "기초유") return "기초유";
if (text === "기초버림") return "기초버림";
return null;
}
function trenchPath(layer: SVGElement, corners: Array<[number, number]>, tooltip: string): void {
const line = document.createElementNS(SVG_NS, "polyline");
line.setAttribute("points", corners.map(([px, py]) => `${px},${py}`).join(" "));
line.setAttribute("class", "b06-chart__excavation");
const title = document.createElementNS(SVG_NS, "title");
title.textContent = tooltip;
line.append(title);
layer.append(line);
}
/**
* ** ** .
* ** **( ).
*/
export function appendPipeTrench(
layer: SVGElement,
input: {
/** 관 중심 offset(m). */
centerOffsetM: number;
/** 관 바닥(invert) 표고. */
invertM: number;
/** 터파기 윗면 표고 — 관 위 지반(또는 노면)선. */
topM: number;
diameterMm: number | null | undefined;
},
x: (offset: number) => number,
toDisplayY: (elevation: number) => number,
): number | null {
const width = pipeTrenchWidthM(input.diameterMm);
if (width === null || !(input.topM > input.invertM)) return null;
const half = width / 2;
const left = input.centerOffsetM - half;
const right = input.centerOffsetM + half;
trenchPath(
layer,
[
[x(left), toDisplayY(input.topM)],
[x(left), toDisplayY(input.invertM)],
[x(right), toDisplayY(input.invertM)],
[x(right), toDisplayY(input.topM)],
],
`관 터파기 폭 ${width.toFixed(2)}m · 깊이 ${(input.topM - input.invertM).toFixed(2)}m\n` +
`Φ${Math.round(Number(input.diameterMm))}㎜ — ${BASIS_PIPE}`,
);
return width;
}
/**
* ****. + 0.2, .
* `foundation` ** ** .
*/
export function appendWallTrench(
layer: SVGElement,
wall: WallLayout,
foundation: "기초유" | "기초버림" | null,
x: (offset: number) => number,
toDisplayY: (elevation: number) => number,
): { widthM: number; depthM: number } | null {
if (foundation === null) return null;
const hasFoundation = foundation === "기초유";
const depth = hasFoundation ? WALL_FOUNDATION_DEPTH_M : WALL_BLINDING_DEPTH_M;
// 폭: 기초 몫은 정본이 정한 값(0.9 / 0.7), 다만 벽 바닥이 그보다 넓으면 그 폭 + 여유.
const baseWidth = Math.abs(wall.bottomFront.offset - wall.bottomBack.offset);
const minWidth = hasFoundation ? WALL_FOUNDATION_WIDTH_M : WALL_BLINDING_WIDTH_M;
const width = Math.max(minWidth, baseWidth + (hasFoundation ? WALL_TRENCH_CLEARANCE_M : 0));
const center = (wall.bottomFront.offset + wall.bottomBack.offset) / 2;
const top = Math.max(wall.bottomFront.elevation, wall.bottomBack.elevation);
const bottom = top - depth;
const left = center - width / 2;
const right = center + width / 2;
trenchPath(
layer,
[
[x(left), toDisplayY(top)],
[x(left), toDisplayY(bottom)],
[x(right), toDisplayY(bottom)],
[x(right), toDisplayY(top)],
],
`${foundation} 터파기 폭 ${width.toFixed(2)}m · 깊이 ${depth.toFixed(2)}m (수직)\n` +
`품셈에 규정이 없어 실무 관행으로 정한 값 — ${BASIS_WALL}`,
);
return { widthM: width, depthM: depth };
}
@@ -37,6 +37,7 @@ import {
type WallAdjust,
type WallLayout,
} from "./B06_Section_UI_Cross_Culvert_Types";
import { appendWallTrench, foundationChoice } from "./B06_Section_UI_Cross_Excavation";
import { appendPlanLine, buildRevetWallGeometry, drawRevetWall } from "./B06_Section_UI_Cross_Wall";
const SVG_NS = "http://www.w3.org/2000/svg";
@@ -53,6 +54,8 @@ export interface RevetmentSpec {
height_m?: number | null;
/** 사용자가 고른 설치 측 — "좌" | "우". */
side?: string | null;
/** 기초 축 — "기초유" | "기초버림". **비어 있으면 터파기를 안 그린다**(근거 없음). */
foundation?: string | null;
/** 단 수(다단). 1이면 단일 벽. */
tiers?: number | null;
/** (구 모델) 기준 올림·좌우 이동 — 배관식 전환 뒤 자리는 조정창 4축(x·d)이 정한다. */
@@ -89,6 +92,8 @@ export interface RevetmentLayout {
top: RevetPoint;
/** 벽 목록(1단 + 다단) — 배관 벽과 같은 `WallLayout`, 그리기가 그대로 쓴다. */
walls: WallLayout[];
/** 기초 축 — 저장 칸과 **같은 글자**(`foundation`). 터파기 그림이 이 값으로 갈린다. */
foundation: string | null;
/** 벽 사이·벽 아래 성토부선(배관 다단과 같은 체계). */
fillSegments: OutletFillSegment[];
/** 성토 설계선을 벽 상단에서 끊고 노견→벽 성토선을 대신 그리는 트림. */
@@ -304,6 +309,7 @@ export function computeRevetmentLayout(
requestedTiers,
top: jt,
walls,
foundation: typeof spec.foundation === "string" ? spec.foundation.trim() || null : null,
fillSegments: extras.segments,
designTrim,
appliedAdjust: { x: appliedX, d: appliedDrop, h: adjust?.h ?? null, m: null },
@@ -359,6 +365,11 @@ export function appendRevetmentOverlay(
` · 성토 물매 1:${(segment.ratio ?? 1.2).toFixed(2)}`,
);
}
// 터파기는 벽보다 **먼저** — 파선이 벽 밑에 깔린다. 기초 축이 비어 있으면 안 그린다.
const trenchFoundation = foundationChoice(layout.foundation);
for (const wall of layout.walls) {
appendWallTrench(svg, wall, trenchFoundation, x, y);
}
const drawn: SVGPolygonElement[] = [];
layout.walls.forEach((wall, index) => {
const keyId = index === 0 ? "own" : `own-extra${index - 1}`;
@@ -0,0 +1,95 @@
/* =============================================================================
* B06_Section_UI_Cross_SpoilFill.ts
* **() ** .
*
* 6 3 ** **
* (DB `01_임도/02_상세설계/유용토운반작업장.md` §2).
* .
*
* ** ** , +.
* (2026-09-09 ).
* (`spoil_fill_*`) ** **.
* ========================================================================== */
const SVG_NS = "http://www.w3.org/2000/svg";
/** 설계 결과에서 사토장 그리기에 쓰는 값만 추려 받는다. */
export interface SpoilFillDrawing {
spoil_fill_line?: Array<{ offset_m: number; elevation_m: number }> | null;
spoil_fill_area_m2?: number | null;
spoil_fill_width_m?: number | null;
spoil_fill_unclosed?: boolean | null;
spoil_fill_capacity_m3?: number | null;
spoil_fill_placed_m3?: number | null;
spoil_fill_unplaced_m3?: number | null;
}
/** 말풍선 문구 — 무엇이 얼마나 쌓였는지와 **근거**를 함께 적는다. */
export function spoilFillTooltip(design: SpoilFillDrawing): string {
const area = Number(design.spoil_fill_area_m2 ?? 0);
const width = Number(design.spoil_fill_width_m ?? 0);
const lines = [
`유용토운반작업장(구 사토장) · 단면 ${area.toFixed(2)}㎡ · 폭 ${width.toFixed(2)}m`,
];
const capacity = design.spoil_fill_capacity_m3;
if (typeof capacity === "number" && capacity > 0) {
const placed = Number(design.spoil_fill_placed_m3 ?? 0);
lines.push(`구간 용량 ${capacity.toFixed(1)}㎥ 중 ${placed.toFixed(1)}㎥ 담김`);
const unplaced = Number(design.spoil_fill_unplaced_m3 ?? 0);
// ⚠ **적어 보이는 0 을 경고로 띄우지 않는다**(2026-09-09 화면 실측). 폭을 이분법으로
// 찾으므로 용량과 담긴 양이 소수점 아래에서 조금 남는다(400 399.9937 = 0.0063).
// 그것을 「못 담음」으로 띄우면 **늘 경고가 뜬 채**가 되어 진짜 경고가 안 보인다.
// 표시 자릿수(0.1㎥)에서 보이지 않는 몫은 없는 것으로 본다.
if (unplaced >= 0.05) {
lines.push(`${unplaced.toFixed(1)}㎥ 는 못 담음 — 지반 자료가 있는 데까지만 넓힘`);
}
}
if (design.spoil_fill_unclosed) {
lines.push("⚠ 비탈이 원지반을 못 만나 잘림 — 지반 자료 범위를 넘어감");
}
lines.push("폭은 노면 끝(노견이 시작하는 자리)에서 잼 — 그 구간 노견도 이 성토 안에 듦");
lines.push("교본 6장 3절이 평면도·횡단도 표시를 요구함");
return lines.join("\n");
}
/**
* . .
* ( `null`) .
*/
export function appendSpoilFillOverlay(
layer: SVGElement,
design: SpoilFillDrawing | null | undefined,
x: (offset: number) => number,
toDisplayY: (elevation: number) => number,
): SVGElement | null {
const line = design?.spoil_fill_line;
if (!design || !Array.isArray(line) || line.length < 2) return null;
const polyline = document.createElementNS(SVG_NS, "polyline");
polyline.setAttribute(
"points",
line.map((point) => `${x(point.offset_m)},${toDisplayY(point.elevation_m)}`).join(" "),
);
polyline.setAttribute(
"class",
design.spoil_fill_unclosed ? "b06-chart__spoil-fill is-unclosed" : "b06-chart__spoil-fill",
);
const title = document.createElementNS(SVG_NS, "title");
title.textContent = spoilFillTooltip(design);
polyline.append(title);
layer.append(polyline);
// 이름표 — 평상 한가운데 위에 얹는다. 선만 있으면 그것이 무엇인지 도면에서 모른다.
const first = line[0];
const last = line[line.length - 1];
const label = document.createElementNS(SVG_NS, "text");
label.setAttribute("x", String((x(first.offset_m) + x(last.offset_m)) / 2));
label.setAttribute("y", String(toDisplayY(Math.max(first.elevation_m, last.elevation_m)) - 4));
label.setAttribute("text-anchor", "middle");
label.setAttribute("class", "b06-chart__spoil-fill-label");
label.textContent = `유용토운반작업장 ${Number(design.spoil_fill_area_m2 ?? 0).toFixed(2)}`;
const labelTitle = document.createElementNS(SVG_NS, "title");
labelTitle.textContent = spoilFillTooltip(design);
label.append(labelTitle);
layer.append(label);
return polyline;
}
+11 -1
View File
@@ -18,6 +18,8 @@ import {
appendFordPavementOverlay,
appendFordSurfaceDropPlan,
} from "./B06_Section_UI_Cross_Ford_Pavement";
import { culvertWallsStandAt } from "./B06_Section_UI_Cross_Culvert_Wire";
import { appendSpoilFillOverlay } from "./B06_Section_UI_Cross_SpoilFill";
import { appendRevetmentOverlay, computeRevetmentLayout } from "./B06_Section_UI_Cross_Revetment";
import {
appendCrossDesignOverlay,
@@ -422,7 +424,12 @@ export function createCrossSectionCard(
// 이 측점에 배관/숨김 기슭막이 세트가 직접 붙었거나(section.culvert) **연동으로
// 옆에서 이어져 온**(culvertLink) 경우엔 배관 경로가 그린다 — 옛 D경로는 건너뛴다
// (둘 다 그리면 이웃 카드에 벽이 겹친다 — 2026-08-28 이관 이중그리기 방지).
if (!section.culvert && !culvertLink) {
// ⚠ 가드를 **좁혔다**(2026-09-09) — 예전에는 링크가 **있기만 하면** 막았는데,
// 관이 아홉·열하나인 노선에서는 링크가 거의 모든 측점을 덮어 **구조물을 놓아도
// 횡단도에 아무것도 안 보였다**(사용자에게 보이던 결함). 겹침 방지라는 까닭은
// 그대로 두고, **그 링크의 벽이 이 카드에 실제로 설 때만** 막는다.
const linkedWallsHere = !!culvertLink && culvertWallsStandAt(culvertLink.source, section);
if (!section.culvert && !linkedWallsHere) {
const ownAdjust = revetOffset?.adjustFor(section, "own");
const ownLayout = computeRevetmentLayout(section, ownAdjust);
ownDesignTrim = ownLayout?.designTrim;
@@ -461,6 +468,9 @@ export function createCrossSectionCard(
// 같은 트림 값을 쓰므로 "그림은 이런데 수량은 저렇다"가 생기지 않는다.
applyStructureAreas(section, designTrim);
appendCrossDesignOverlay(plotLayer, section.design, x, toDisplayY, drawSamples, designTrim);
// 사토장(유용토운반작업장) — 교본 6장 3절이 횡단도 표시를 요구한다. 값은 설계 결과에서
// 그대로 오고 여기서 다시 세지 않는다. 선 종류는 터파기 파선과 갈라 둔다.
appendSpoilFillOverlay(plotLayer, section.design, x, toDisplayY);
// 암 경계선 = 지면선 복사 + 오프셋(계획선 기준 아님).
if (rockBoundary && section.design.geometry_preset === "rock") {
appendRockBoundaryOverlay(
@@ -0,0 +1,99 @@
/* =============================================================================
* B06_Section_UI_Missing_Stations.ts
* N개 + [ ] ( 3-14 ).
*
* **B05 [] **,
* . **·
* **(실측: 배수관 B09 ).
*
* ( ).
* ** ** .
* ** **
* .
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { createButton, showToast } from "@ui/ui_template_elements";
interface MissingStation {
chainage_m: number;
label: string;
}
interface MissingResponse {
missing?: MissingStation[];
can_create?: boolean;
reason?: string;
created?: number;
message?: string;
}
async function call(projectId: string, method: "GET" | "POST"): Promise<MissingResponse> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/section/missing-stations`, {
method,
credentials: "include",
headers: { "Content-Type": "application/json" },
});
const payload = (await response.json()) as MissingResponse;
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
return payload;
}
/**
* `host` . .
* `onCreated` ( ).
*/
export async function mountMissingStationNotice(
host: HTMLElement,
projectId: string,
onCreated: () => void | Promise<void>,
): Promise<void> {
let data: MissingResponse;
try {
data = await call(projectId, "GET");
} catch {
return; // 점검이 안 되는 것으로 화면을 막지 않는다 — 이 줄은 덤이다.
}
const missing = data.missing ?? [];
if (!missing.length) return;
const box = document.createElement("div");
box.className = "b06-missing-stations";
const text = document.createElement("p");
text.className = "b06-missing-stations__text";
const where = missing
.slice(0, 6)
.map((item) => `${item.chainage_m.toFixed(2)}m ${item.label}`)
.join(" · ");
text.textContent =
`측점이 없는 구조물 ${missing.length}개 — ${where}` +
(missing.length > 6 ? `${missing.length - 6}` : "") +
". 이 자리는 횡단도에도 안 서고 수량에서도 빠집니다.";
box.append(text);
if (data.can_create) {
const button = createButton({ label: "측점 만들기", variant: "filled" });
button.addEventListener("click", async () => {
button.disabled = true;
button.textContent = "만드는 중…";
try {
const result = await call(projectId, "POST");
showToast(`측점 ${result.created ?? 0}개를 만들었습니다.`, "success");
box.remove();
await onCreated();
} catch (error) {
const detail = error instanceof Error ? ` ${error.message}` : "";
showToast(`측점을 만들지 못했습니다.${detail}`, "error");
button.disabled = false;
button.textContent = "측점 만들기";
}
});
box.append(button);
} else if (data.reason) {
const reason = document.createElement("p");
reason.className = "b06-missing-stations__reason";
reason.textContent = data.reason;
box.append(reason);
}
host.prepend(box);
}
+5 -1
View File
@@ -4,6 +4,7 @@ import { leaveForDashboard } from "../A00_Common/b_missing_data_guard";
import { readByKey, stateKey, writeByKey } from "../A00_Common/b_page_state";
import { navigateTo } from "../A00_Common/router";
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
import { mountMissingStationNotice } from "./B06_Section_UI_Missing_Stations";
import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { attachCollapsible } from "@ui/ui_template_collapsible";
@@ -272,7 +273,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
ditch_type: design.ditch_type ?? "standard",
paved: design.paved,
two_stage_slope: design.two_stage_slope ?? true,
ditch_enabled: design.ditch_enabled ?? null,
ditch_choice: design.ditch_choice ?? null,
};
}
@@ -750,6 +751,9 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
if (storedOptions?.station_interval_m && storedOptions.station_interval_m > 0)
stationInterval = storedOptions.station_interval_m;
renderSectionDetail();
// 측점이 없는 구조물 알림 — 관을 나중에 놓으면 그 측점이 안 생겨 수량에서 조용히 빠진다
// (계획서 3-14 ㉯). 만드는 것은 사용자가 누를 때만.
void mountMissingStationNotice(root, projectId, refreshDetailForStructures);
void reconcileStaleDesigns(); // 옛 암 2단계 + 종단 변경 반영 자동 재계산(E-1 + N-6)
updateActionState();
} catch (error) {
+9
View File
@@ -95,6 +95,15 @@
display: none;
}
/* 창이 좁아 좌측 패널이 237px 까지 물러난 자리 라벨이 줄어 2px 새어 나왔다.
폭에서만 라벨을 윗줄로 접는다. 넓은 배치는 손대지 않는다(2026-09-08 실측).
제한 없이 `flex-wrap` 주면 넓은 폭에서도 라벨이 윗줄로 올라간다 보고 되돌렸다. */
@media (max-width: 560px) {
.b06-profile__field-row > .ui-field {
flex-wrap: wrap;
}
}
.b06-profile__field-row > .ui-btn {
flex: 0 0 auto;
}
@@ -71,6 +71,11 @@
color: var(--color-chart-0);
}
/* 사토장 — 성토와 **다른 색**으로 둔다. 같은 색이면 표에서 한 덩어리로 읽힌다. */
.b06-design__area--spoil-fill {
color: #6d7a20;
}
.b06-design__area--unset {
color: var(--color-text-muted);
}
@@ -379,3 +384,61 @@
stroke-dasharray: 4 3;
stroke-linejoin: round;
}
/* 터파기 (2026-09-09 사용자 확정 ) **파선**으로 그린다. 구조물 실체가 아니라
여기를 판다 표시이고, 근거의 (법정 / 실무 관행) 말풍선에 적힌다. */
.b06-chart__excavation {
fill: none;
stroke: #8a5c3c;
stroke-dasharray: 4 3;
stroke-width: 1;
}
/* 사토장(유용토운반작업장) **터파기 파선과 종류를 가른다**(2026-09-09).
터파기는 짧은 점선(4 3), 사토장은 ** 파선 + **(9 3 2 3)이라 한눈에 갈린다.
색은 등록부 사토장 (#8c9a2e) 그대로 쓴다. */
.b06-chart__spoil-fill {
fill: none;
stroke: #8c9a2e;
stroke-dasharray: 9 3 2 3;
stroke-width: 1.4;
}
.b06-chart__spoil-fill-label {
fill: #6d7a20;
font-size: 10px;
paint-order: stroke;
stroke: rgba(255, 255, 255, 0.9);
stroke-width: 3;
}
/* 지반을 못 만나 잘린 사토장 — 사면 미폐합 경고와 같은 결로 붉게 알린다. */
.b06-chart__spoil-fill.is-unclosed {
stroke: #c0392b;
}
/* 「측점 없는 관 N개」 알림 — 조용히 빠지던 것을 드러내는 줄(계획서 3-14 ㉯). */
.b06-missing-stations {
align-items: center;
background: #fff8e6;
border: 1px solid #e0b872;
border-radius: 6px;
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
margin: 0 0 12px;
padding: 10px 12px;
}
.b06-missing-stations__text {
color: #7a5a12;
flex: 1 1 320px;
margin: 0;
}
.b06-missing-stations__reason {
color: #8a6a22;
flex: 1 1 100%;
font-size: 12px;
margin: 0;
}
@@ -15,6 +15,7 @@ export interface DesignDrawingItem {
| "landuse"
| "plan_lidar"
| "cross_standard"
| "standard"
| "blank";
label: string;
chainage_m: number | null;
@@ -46,6 +47,8 @@ export interface DesignDrawingListResponse {
project_id: string;
route_id: number;
drawings: DesignDrawingItem[];
/** 확정을 건너뛰고 개발 우회로로 열렸나 — 화면이 그 사실을 알려야 한다. */
dev_bypass?: boolean;
}
/** 수량 산출표 값 (미산정 항목은 null). 백엔드 `_quantity_table`의 키와 대응. */
@@ -100,6 +103,7 @@ export interface DesignDrawingResponse {
| "landuse"
| "plan_lidar"
| "cross_standard"
| "standard"
| "blank";
label: string;
drawing: CadDrawing;
@@ -241,3 +245,48 @@ export function resetFrameTemplate(projectId: string): Promise<void> {
method: "DELETE",
});
}
/** 표준도 장 목록 — 제원 입력 칸이 쓰는 것만 추린 꼴. */
export interface StandardSheetsResponse {
status: string;
sheet_count: number;
structure_count: number;
sheets: {
key: string;
title: string;
type_id: string;
member_count: number;
options: Record<string, unknown>;
}[];
}
export function fetchStandardSheets(projectId: string): Promise<StandardSheetsResponse> {
return requestJson(`/projects/${projectId}/standard-sheets`);
}
/** 장 하나의 제원 저장 — 빈 값(null)은 **그 칸을 지우라**는 뜻이다. */
export function putStandardSheetSpec(
projectId: string,
body: {
sheet_key: string;
base_revision: number;
stone_kind: string | null;
stone_supply: string | null;
back_len_cm: string | null;
face_slope_ratio: string | null;
foundation: string | null;
stone_coeff_basis: string | null;
fill_concrete_mpa: string | null;
},
): Promise<{ status: string; revision: number; changed: number; notes: string[] }> {
return requestJson(`/projects/${projectId}/standard-sheets/spec`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
/** 구조물 정본 판번호 — 제원을 저장할 때 함께 보내야 다른 창 덮어쓰기를 막는다. */
export function fetchStructureRevision(projectId: string): Promise<{ revision: number }> {
return requestJson(`/projects/${projectId}/route/structures`);
}
@@ -0,0 +1,270 @@
"""표준도 **위쪽 그림** — 돌쌓기 단면(2단계, 2026-09-09).
실무 원본 탭이 치수조서 + 아래 수량산출서인데, 아래 표는 1단계에서 섰고 여기가 위다.
**기울기를 값으로 받는다 0.3 박지 않는다.**
판정은 **B08 `face_slope_ratio()` ** 그대로 부른다(품셈 13-4-4 [] 표준경사
직고·메찰·성절토). 사용자가 정했으면 값이 이긴다(확정 ). 그림은 **수량이 바로
**으로 기울고, 근거 문구도 같이 받아 그림에 적는다 판정을 벌로 짜면 그림과
수량이 갈린다.
**수량이 쓰는 상수로 그린다** `B08_Quantity_Engine_UnitQuantity.STONE_MASONRY`
직접 읽는다. 치수를 여기서 다시 적으면 **그림과 표가 갈린다**(CLAUDE.md 5).
상부 두께 = 뒷길이 + 0.30 하부 두께 = 상부 + 0.30 × (H 1.0)
터파기 = 평균두께 + 0.2 기초 0.5×0.9 (기초유) · 0.1×0.7 (기초버림)
터파기 치수는 **`common_util_excavation` ** 읽는다 횡단도가 쓰는 상수다.
여기서 다시 적으면 도면이 다른 터파기를 그린다.
**뒷길이가 두께에 들어간다**(확정 2 , 실무 구조물도 ). 뒷길이가 다르면 벽이
두꺼워지고 그림도 그만큼 넓어진다 예전 (0.45+0.10H / 0.45+0.40H) 뒷길이를
아예 봐서 35 45 같은 그림이 나왔다.
뒷길이를 정한 장은 두께를 낸다 그때는 **그리지 않는다**(0 으로 때우면
거짓 그림이 된다).
**치수가 없는 것은 그리되 치수를 적지 않는다** 막자갈(뒷채움) 우리 식이 입적에서
몸통·고임돌을 이라 ·높이로 정의된 도형이 아니다. 자리만 보이고 값은 표를 가리킨다.
없는 치수를 그림에 적으면 **표와 다른 번째 정본** 생긴다.
"""
from __future__ import annotations
from typing import Any
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
_line_entity,
_text_entity,
polyline_entity,
)
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import STONE_MASONRY, face_slope_ratio
from common_util.common_util_excavation import (
WALL_BLINDING_DEPTH_M,
WALL_BLINDING_WIDTH_M,
WALL_FOUNDATION_DEPTH_M,
WALL_FOUNDATION_WIDTH_M,
WALL_TRENCH_CLEARANCE_M,
)
#: 그림 축척 — 1/25(1m = 40㎜). 표(줄 높이 9㎜) 위에 얹어도 한 면에 드는 크기다.
SCALE_MM_PER_M = 40.0
#: 이 그림을 그리는 종류. 옹벽·집수정처럼 단면이 다른 것은 아직 그리지 않는다.
FIGURE_TYPE_IDS: frozenset[str] = frozenset({"masonry_wet", "masonry_dry", "boulder_masonry"})
_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"]
bottom_t = top_t + STONE_MASONRY["thickness_slope_per_m"] * max(
height_m - STONE_MASONRY["thickness_height_base_m"], 0.0
)
return top_t, bottom_t
def back_length_cm(sheet: dict[str, Any]) -> float | None:
"""장의 뒷길이(㎝). 안 정했으면 `None` — 그때는 두께를 못 내므로 그리지 않는다."""
options = sheet.get("options") or {}
raw = options.get("back_len_cm") or options.get("stone_back_length_cm")
try:
return float(raw) if raw not in (None, "") else None
except (TypeError, ValueError):
return None
def section_points(
height_m: float, slope_ratio: float, back_cm: float
) -> list[tuple[float, float]]:
"""벽 단면 네 점(m 단위, 밑면 앞끝이 원점). 앞면이 뒤로 `n·H` 기운다."""
top_t, bottom_t = wall_thickness(height_m, back_cm)
lean = slope_ratio * height_m
return [
(0.0, 0.0),
(lean, height_m),
(lean + top_t, height_m),
(bottom_t, 0.0),
(0.0, 0.0),
]
def build_figure(
drawing_id: str,
sheet: dict[str, Any],
layer_id: str,
origin: tuple[float, float],
line_color: str,
label_color: str,
guide_color: str,
) -> tuple[list[dict[str, Any]], float]:
"""장 하나의 그림. `(엔티티, 그림이 차지한 높이 ㎜)` — 못 그리면 `([], 0)`."""
if str(sheet.get("type_id") or "") not in FIGURE_TYPE_IDS:
return [], 0.0
height_m = float(sheet.get("height_m") or 0.0)
if height_m <= 0:
return [], 0.0
back_cm = back_length_cm(sheet)
if back_cm is None:
# 뒷길이가 없으면 두께를 못 낸다 — 0 으로 때우지 않고 그리지 않는다.
return [], 0.0
slope, slope_note = slope_of(sheet)
scale = SCALE_MM_PER_M
ox, oy = origin
def mm(point: tuple[float, float]) -> tuple[float, float]:
return (ox + point[0] * scale, oy + point[1] * scale)
points = section_points(height_m, slope, back_cm)
top_t, bottom_t = wall_thickness(height_m, back_cm)
average_t = (top_t + bottom_t) / 2.0
dig_width = average_t + WALL_TRENCH_CLEARANCE_M
# 기초 몫 — 「기초유 / 기초버림」이 폭·깊이를 가른다(정본 탭 제목).
foundation = str((sheet.get("options") or {}).get("foundation") or "")
if foundation == "기초유":
base_w, base_d = WALL_FOUNDATION_WIDTH_M, WALL_FOUNDATION_DEPTH_M
elif foundation == "기초버림":
base_w, base_d = WALL_BLINDING_WIDTH_M, WALL_BLINDING_DEPTH_M
else:
base_w = base_d = 0.0 # 안 정한 장은 기초를 안 그린다 — 지어내지 않는다.
entities: list[dict[str, Any]] = []
wall = polyline_entity(
f"{drawing_id}:fig:wall", [mm(p) for p in points], layer_id, line_color, width=2
)
if wall is not None:
entities.append(wall)
# 터파기 — 우리 수량식(평균두께+0.2) × 높이 를 **그대로** 그린다. 점선.
dig = polyline_entity(
f"{drawing_id}:fig:dig",
[mm(p) for p in ((0.0, 0.0), (0.0, height_m), (dig_width, height_m), (dig_width, 0.0))],
layer_id,
guide_color,
dash=[4, 3],
)
if dig is not None:
entities.append(dig)
# 기초 — 벽 밑에 놓이는 칸. 「안 정함」이면 안 그린다.
if base_d > 0:
base = polyline_entity(
f"{drawing_id}:fig:base",
[mm(p) for p in ((0.0, 0.0), (0.0, -base_d), (base_w, -base_d), (base_w, 0.0))],
layer_id,
guide_color,
)
if base is not None:
entities.append(base)
# 물구멍 — 벽을 가로지르는 짧은 선 하나. 개소 간격은 글자로 적는다(면적당이라 그림에 못 씀).
weep_y = height_m * 0.5
entities.append(
_line_entity(
f"{drawing_id}:fig:weep",
mm((slope * weep_y, weep_y)),
mm((slope * weep_y + top_t, weep_y)),
layer_id,
guide_color,
)
)
labels: list[tuple[str, tuple[float, float], str]] = [
(f"H = {height_m:g} m", (-0.28, height_m / 2.0), "right"),
(f"상부 {top_t:.2f} m", (slope * height_m + top_t / 2.0, height_m + 0.14), "center"),
(f"하부 {bottom_t:.2f} m", (bottom_t / 2.0, -0.30), "center"),
(f"1 : {slope:g}", (slope * height_m / 2.0 - 0.30, height_m * 0.72), "right"),
(
f"터파기 폭 {dig_width:.2f} m (평균두께 {average_t:.2f} + 0.20)",
(dig_width + 0.16, height_m * 0.92),
"left",
),
(
f"기초 {base_w:g} × {base_d:g} m ({foundation})"
if base_d > 0
else "기초 — 안 정함(기초유/기초버림)",
(dig_width + 0.16, -base_d / 2.0 if base_d else 0.0),
"left",
),
(
f"물구멍 — {STONE_MASONRY['weep_hole_area_m2']:g}㎡당 1개소",
(slope * weep_y + top_t + 0.16, weep_y),
"left",
),
("막자갈(뒷채움) — 수량은 아래 표", (dig_width + 0.16, height_m * 0.55), "left"),
]
labels.append((f"뒷길이 ℓ₃ = {int(back_cm)}", (dig_width + 0.16, height_m * 0.72), "left"))
for index, (text, point, align) in enumerate(labels):
x, y = mm(point)
entities.append(
_text_entity(
f"{drawing_id}:fig:label:{index}",
text,
x,
y,
layer_id,
_DIM_FONT,
label_color,
align,
)
)
# 기울기 근거는 **그림 밑에** 한 줄 — 「사용자 지정」인지 「품셈 표준경사」인지 보여야 한다.
note_x, note_y = mm((0.0, -0.62))
entities.append(
_text_entity(
f"{drawing_id}:fig:slopenote",
f"전면 기울기 — {slope_note}",
note_x,
note_y,
layer_id,
_DIM_FONT,
label_color,
"left",
)
)
title_x, title_y = mm((0.0, height_m + 0.52))
entities.append(
_text_entity(
f"{drawing_id}:fig:title",
f"{sheet.get('title') or '돌쌓기'} (축척 1/{int(1000 / SCALE_MM_PER_M)})",
title_x,
title_y,
layer_id,
_LABEL_FONT,
label_color,
"left",
)
)
used_mm = (height_m + 0.95 + base_d) * scale
return entities, used_mm
@@ -0,0 +1,270 @@
"""표준도(구조물도) 도면 — **하단표를 CAD 표로 그린다**.
실무 원본(`07-구조도-소광리.xlsx`) 하나가 치수조서 + 아래 수량산출서.
여기서 그리는 것은 **아래쪽 **이고, 위쪽 그림은 2단계(돌쌓기 그림)에서 붙는다.
**도각을 두르지 않는다** 2026-09-08 사용자 지시(도각·표제란 필요 없음).
그래서 `frame_entities` 부르지 않는다(도면마다 끝에서 부르는 구조라 부르면 ).
** 장에 여러 표를 세로로 쌓는다**(2026-09-09 현재). 제원 조합마다 장을 따로 내는 것은
계획평면도가 쓰는 나눔 본을 따라야 하고 목록·라우터 곳이 함께 움직여야 한다().
지금은 **값이 눈에 보이는 ** 먼저라 장에 쌓고, 나눔은 다음이다.
값을 여기서 셈하지 않는다 `_Engine_Standard_Sheet.build_standard_sheets` 것을
글자로 옮길 뿐이다. 셈이 벌이 되면 도면과 수량서가 갈린다(CLAUDE.md 5).
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
DRAWING_FORMAT,
TABLE_LABEL_COLOR,
TABLE_LINE_COLOR,
TABLE_VALUE_COLOR,
_format,
_layer,
table_entity,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardFigure import (
SCALE_MM_PER_M as FIGURE_SCALE_MM_PER_M,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardFigure import build_figure
from B07_DesignDetail.B07_DesignDetail_Engine_Template import usable_bbox
logger = logging.getLogger(__name__)
STANDARD_LAYER_ID = "standard"
#: 장 하나가 도면 하나 — 계획평면도와 같은 본을 따른다(`plan_lidar` / `plan_lidar_2`).
SHEET_ID_PREFIX = "standard_sheet"
def sheet_drawing_id(number: int, total: int) -> str:
"""장이 하나면 `standard_sheet`, 여럿이면 `standard_sheet_2` 처럼 번호를 붙인다."""
return SHEET_ID_PREFIX if total <= 1 and number <= 1 else f"{SHEET_ID_PREFIX}_{number}"
# 종이 밀리미터. 실무 시트가 「공종 | 산출근거 | 수량 | 단위」 넉 줄이라 그대로 간다.
# ⚠ **산출근거 칸을 넓게 잡는다** — 처음 176㎜ 로 냈더니 물구멍관·버림콘크리트처럼 단서가
# 붙은 근거 문구가 칸을 넘어 수량 칸을 덮었다(2026-09-09 실화면). 표는 글자를 안 접는다.
# A1 작도 영역이 780㎜ 남짓이므로 그 폭을 그대로 쓴다.
# A1 작도 영역이 **739 × 499㎜** 라 그 폭을 그대로 쓴다(합 735㎜).
# ⚠ 줄 높이 9㎜ 는 **네 장까지** 한 면에 들어가는 크기다(47줄 × 9 + 간격 = 465㎜).
# 장이 더 늘면 세로로 넘친다 — 그때가 장 나눔(㉢)을 할 때다.
_COLUMN_WIDTHS: tuple[float, ...] = (90.0, 535.0, 70.0, 40.0)
_ROW_HEIGHT = 9.0
_FONT = 4.2
#: 표 사이 간격 — 장이 여럿일 때 서로 붙어 보이지 않게.
_GAP = 14.0
#: 그림을 왼쪽 여백에서 조금 들여 놓는다 — 치수 글자가 왼쪽으로 나가기 때문.
_FIGURE_INSET = 60.0
#: 그림 제목이 들어갈 윗여백.
_FIGURE_TOP_PAD = 6.0
def _figure_height(sheet: dict[str, Any]) -> float:
"""그림이 차지할 높이(㎜) — 자리를 먼저 비워 두려고 미리 잰다."""
height_m = float(sheet.get("height_m") or 0.0)
return (height_m + 0.95) * FIGURE_SCALE_MM_PER_M if height_m > 0 else 0.0
def _cell(
text: str, color: str, *, align: str = "left", col_span: int = 1, bold: bool = False
) -> dict[str, Any]:
cell: dict[str, Any] = {"text": text, "color": color, "align": align}
if col_span > 1:
cell["colSpan"] = col_span
if bold:
cell["bold"] = True
return cell
def _sheet_rows(sheet: dict[str, Any]) -> list[list[dict[str, Any] | None]]:
"""장 하나를 표 격자로. 제목 · 단위 · 머리 · 본문 · (막힌 사유)."""
span = len(_COLUMN_WIDTHS)
rows: list[list[dict[str, Any] | None]] = []
title = str(sheet.get("title") or "표준도")
members = int(sheet.get("member_count") or 0)
total = float(sheet.get("billing_total") or 0.0)
unit = str(sheet.get("billing_unit") or "")
head = f"{title}{members}개소"
if total:
head += f" · 합 {_format(total)}{unit}"
rows.append([_cell(head, TABLE_LABEL_COLOR, col_span=span, bold=True)] + [None] * (span - 1))
# 실무 시트가 머리 위에 「m당」을 따로 적는다 — 단위가 종류마다 달라서다.
rows.append(
[_cell(str(sheet.get("unit_label") or ""), TABLE_LABEL_COLOR, align="right", col_span=span)]
+ [None] * (span - 1)
)
rows.append(
[
_cell("공종", TABLE_LABEL_COLOR, align="center"),
_cell("산 출 근 거", TABLE_LABEL_COLOR, align="center"),
_cell("수량", TABLE_LABEL_COLOR, align="center"),
_cell("단위", TABLE_LABEL_COLOR, align="center"),
]
)
for row in sheet.get("rows") or []:
amount = row.get("unit_amount")
# ⚠ 못 낸 값은 **0 이 아니라 「-」** 로 적는다. 도면에 0 이 찍히면 「없다」로 읽힌다.
text = _format(amount) if isinstance(amount, (int, float)) else "-"
rows.append(
[
_cell(str(row.get("name") or ""), TABLE_VALUE_COLOR),
_cell(str(row.get("basis") or ""), TABLE_LABEL_COLOR),
_cell(text, TABLE_VALUE_COLOR, align="right"),
_cell(str(row.get("unit") or ""), TABLE_LABEL_COLOR, align="center"),
]
)
# 막힌 줄의 사유 — 표 밖에 두면 도면을 넘길 때 같이 안 따라간다.
for note in sheet.get("notes") or []:
rows.append([_cell(f"· {note}", TABLE_LABEL_COLOR, col_span=span)] + [None] * (span - 1))
return rows
def build_standard_drawing(drawing_id: str, label: str, payload: dict[str, Any]) -> dict[str, Any]:
"""표준도 한 장 — 장 목록의 표를 위에서 아래로 쌓는다.
구조물이 없으면 ** 대신 줄로 사실을 적는다** 격자만 뜨면
만들다 인지 구조물이 없는 인지 구별이 된다.
"""
x0, _y0, _x1, y1 = usable_bbox()
entities: list[dict[str, Any]] = []
sheets = list(payload.get("sheets") or [])
if not sheets:
reason = (
"구조물이 없어 표준도에 실을 것이 없습니다."
if not payload.get("structure_count")
else "구조물이 모두 다른 단계에서 셈되어 표준도에 실리지 않았습니다."
)
grid = [[_cell(label, TABLE_LABEL_COLOR, bold=True)], [_cell(reason, TABLE_VALUE_COLOR)]]
entities.append(
table_entity(
f"{drawing_id}:empty",
(x0, y1),
[sum(_COLUMN_WIDTHS)],
[_ROW_HEIGHT] * len(grid),
grid,
STANDARD_LAYER_ID,
TABLE_LINE_COLOR,
_FONT,
TABLE_VALUE_COLOR,
)
)
return {
"format": DRAWING_FORMAT,
"entities": entities,
"layers": [_layer(STANDARD_LAYER_ID, "표준도")],
}
top = y1
for index, sheet in enumerate(sheets, start=1):
# 위 그림 — 실무 탭의 「치수조서」 자리. 못 그리는 종류는 표만 선다(2단계).
figure, used = build_figure(
drawing_id,
sheet,
STANDARD_LAYER_ID,
(x0 + _FIGURE_INSET, top - _FIGURE_TOP_PAD - _figure_height(sheet)),
TABLE_LINE_COLOR,
TABLE_LABEL_COLOR,
TABLE_VALUE_COLOR,
)
if figure:
entities.extend(figure)
top -= used + _GAP
grid = _sheet_rows(sheet)
entities.append(
table_entity(
f"{drawing_id}:sheet:{index}",
(x0, top),
list(_COLUMN_WIDTHS),
[_ROW_HEIGHT] * len(grid),
grid,
STANDARD_LAYER_ID,
TABLE_LINE_COLOR,
_FONT,
TABLE_VALUE_COLOR,
)
)
top -= len(grid) * _ROW_HEIGHT + _GAP
return {
"format": DRAWING_FORMAT,
"entities": entities,
"layers": [_layer(STANDARD_LAYER_ID, "표준도")],
}
def standard_payload(
project_root: Path, section_modes: dict[float, str] | None = None
) -> dict[str, Any]:
"""프로젝트 구조물을 제원 조합으로 묶은 장 목록. 실패해도 **빈 목록**을 낸다.
늦게 부른다(함수 import) B08 B05 부르고 B05 다시 B07 부를 있어
모듈 위에서 부르면 맞물린다.
"""
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
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
)
except Exception:
logger.exception("B07 표준도 장 목록 실패 — 빈 목록으로 둔다: %s", project_root)
return {"sheets": [], "structure_count": 0}
def sheet_items(
project_root: Path, section_modes: dict[float, str] | None = None
) -> list[tuple[str, str]]:
"""좌측 목록에 설 `(도면 id, 이름)`. 장이 없으면 **빈 장 하나**를 남긴다.
구조물이 없다고 단추가 통째로 사라지면 없어진 처럼 보인다 눌러서 사유를 읽게 한다.
"""
sheets = standard_payload(project_root, section_modes).get("sheets") or []
if not sheets:
return [(SHEET_ID_PREFIX, "표준도")]
total = len(sheets)
return [
(sheet_drawing_id(index, total), f"표준도 {index}장 ({sheet.get('title') or ''})".strip())
for index, sheet in enumerate(sheets, start=1)
]
def standard_drawing_for(
project_root: Path,
drawing_id: str,
label: str,
section_modes: dict[float, str] | None = None,
) -> dict[str, Any]:
"""프로젝트 구조물을 읽어 표준도 한 장을 만든다 — 라우터가 부르는 문.
실패해도 도면은 연다 구조물 정본을 읽었다고 화면이 비면 사용자는 고장으로
읽는다. 그때는 대신 사유가 적힌 줄이 뜬다.
늦게 부른다(함수 import) B08 B05 부르고 B05 다시 B07 부를 있어
모듈 위에서 부르면 맞물린다.
"""
payload = standard_payload(project_root, section_modes)
sheets = payload.get("sheets") or []
# 이 도면 id 가 가리키는 장 하나만 남긴다 — 한 면에 한 장(2026-09-09, 그림이 붙으면서).
total = len(sheets)
picked = [
sheet
for index, sheet in enumerate(sheets, start=1)
if sheet_drawing_id(index, total) == drawing_id
]
return build_standard_drawing(drawing_id, label, {**payload, "sheets": picked})
@@ -49,7 +49,11 @@ _CROSS_RIGHT_ROWS: tuple[tuple[str | None, str | None], ...] = (
("성토파종", "fill_seeding"),
("절토살포", "cut_spraying"),
("제근", "grubbing"),
(None, None),
# 사토장(유용토운반작업장) — **「쌓기」와 갈라 세운다**(2026-09-09 확정 ㉠).
# ⚠ 여기는 원래 **빈 줄**이었고, 실무 원본 횡단도에는 그 자리가 **아예 없다**
# (2026-09-09 400dpi 실측). 즉 **납품 양식을 늘리는 것이 아니라 안 쓰던 칸을 쓰는 것**이다.
# ⚠ 사토장이 없는 측점에는 값이 안 실려 종전처럼 빈칸으로 남는다.
("사토장", "spoil_fill"),
("노면다짐", "road_compaction"),
)
@@ -74,6 +78,8 @@ QUANTITY_VALUE_KEYS: tuple[str, ...] = (
"fill_seeding",
"cut_spraying",
"grubbing",
# 사토장 — 「쌓기(embankment)」와 **다른 칸**이다. 합치지 않는다(확정 ㉠).
"spoil_fill",
"road_compaction",
)
@@ -0,0 +1,123 @@
"""횡단도 아래 수량 산출표 — **저장된 횡단 설계에서 칸을 채운다**.
필요한가 (법정 요구)
별표2 .1..(5) 횡단면도에 지반고·계획고·절토고·성토고·**단면적·지장목 제거·
측구터파기 단면적·사면보호공** 여덟을 요구한다. 지금 나가는 것은 ** 넷뿐**이고
넷이 빈칸으로 나갔다. 구멍을 메우는 자리다.
빈칸의 까닭은 값이 없어서 아니었다
표가 `source["quantities"]` 보는데 ** 키가 원본 파일에 아예 없다**(실측:
`cross_00960m.json` `samples`·`center_z`·`frame` ). 반면 **저장된 횡단 설계에는
단면적이 그대로 있고**(`cut_soil_area_m2` ), **사면길이도 설계선에서 유도된다**
(`B08_Quantity_Engine_SlopeLength.station_slope`). **통로만 없었다.**
계산을 새로 짜지 않는다 (CLAUDE.md 5)
단면적은 B06 저장값을 **그대로 읽고**, 사면 계열은 **B08 쓰는 함수** 부른다.
계열 이름과 어느 면을 쓰나 `B08_Quantity_Engine_SlopeArea` 정의를 빌려 쓴다
거기서 밑수가 바뀌면 표도 같이 움직여야 하기 때문이다.
사면 계열 칸의 **단위**
B08 측점 사이를 평균단면적법으로 적분해 **면적()** 내지만, 그것은 측점이 있어야
나오는 값이라 ** 장짜리 횡단도에는 쓴다.** 횡단도 칸에 들어가는 것은 측점의
**사면길이(m)** 이고, 이는 **1m 조각의 면적(/m) 수치가 같다.**
표에 m 것인가 /m 것인가 표기 문제이고 **값은 하나다.**
아직 채우는 근거가 없다(임의로 넣지 않는다)
· 측구 토사/암석 저장값이 `ditch_area_m2` ** 값뿐**이라 토사·암석으로 가른다
· 표토제거 성토/절토 법은 전량 제거인데 **두께 칸이 없어** 물량이 선다
· 편책 별도 일위대가를 만들어 잇기로 확정(2026-09-09 -4). 밑수는 그때 붙는다
· 제근 입목 본수를 든다
· 노면다짐 밑수(노면 ) 있으나 표의 다른 칸과 축이 달라 뒤로 미룸
"""
from __future__ import annotations
from typing import Any
from B08_Quantity.B08_Quantity_Engine_SlopeArea import PROTECTION_SOURCE, _key, _length_of
from B08_Quantity.B08_Quantity_Engine_SlopeLength import station_slope
#: 저장된 설계 단면적을 그대로 옮기는 칸 — `표 키 → 설계 키`.
#: ⚠ 측구 둘은 B06 이 **절토 분리와 같은 근거**(지반 유형 + 암반 경계선)로 갈라 낸 값이다
#: (2026-09-09 `6a7c339f`). 여기서 다시 가르지 않는다 — 근거가 없는 측점은 그쪽이
#: **나누지 않고** 사유(`ditch_split_basis`)를 함께 낸다. `split_basis()` 로 읽는다.
AREA_KEYS: tuple[tuple[str, str], ...] = (
("cut_soil", "cut_soil_area_m2"),
("cut_rock", "cut_rock_area_m2"),
("embankment", "fill_area_m2"),
("ditch_soil", "ditch_soil_area_m2"),
("ditch_rock", "ditch_rock_area_m2"),
# 사토장 — **`fill_area_m2` 와 갈라 든다**. B06 이 이미 노선 성토에서 뺀 값이라
# 여기서 더하거나 빼지 않는다(확정 ㉠ — 같은 흙을 두 번 세지 않기).
("spoil_fill", "spoil_fill_area_m2"),
)
#: `ditch_split_basis` 를 사람이 읽는 말로. 도면·화면이 「왜 이렇게 갈렸나」를 보일 때 쓴다.
#: ⚠ `rock_ground_no_boundary` 는 **못 가른 것**이다 — 절반씩 임의로 나누는 대신 전량 암으로
#: 두고 그 사실을 남긴다. 그 측점의 표에는 「암」 한 칸만 서는 것이 정상이다.
DITCH_SPLIT_LABEL: dict[str, str] = {
"no_ditch": "측구 없음",
"soil_ground": "토사 지반 — 전량 토사",
"rock_ground_no_boundary": "암 지반인데 암반 경계선이 없어 **못 가름** — 전량 암",
"rock_boundary": "암반 경계선으로 가름",
}
#: 사면 계열에서 오는 칸 — `표 키 → (B08 계열, 면)`.
#: 성토파종·절토살포는 **법면보호공**이고, 그것은 B08 에서 면고르기를 참조한다
#: (`PROTECTION_SOURCE`). 참조를 끊으면 그쪽 한 곳만 고치면 이 표도 따라온다.
SLOPE_KEYS: tuple[tuple[str, str, str], ...] = (
("benching", "bench_cut", "fill"),
("grading_fill", "face_dressing", "fill"),
("grading_cut", "face_dressing", "cut"),
("tree_removal_fill", "tree_removal", "fill"),
("tree_removal_cut", "tree_removal", "cut"),
("fill_seeding", PROTECTION_SOURCE, "fill"),
("cut_spraying", PROTECTION_SOURCE, "cut"),
)
def _num(value: Any) -> float | None:
return float(value) if isinstance(value, (int, float)) else None
def split_basis(design: dict[str, Any] | None) -> tuple[str, str] | None:
"""측구를 무슨 근거로 갈랐나 — `(코드, 사람이 읽는 말)`. 값이 없으면 `None`.
칸은 숫자만 담으므로 말은 ** **(주기·화면) 실어야 한다. 전량
경계선을 찾아서인지 정말 지반이어서인지는 숫자만 봐서는 없다.
"""
if not isinstance(design, dict):
return None
code = design.get("ditch_split_basis")
if not isinstance(code, str) or not code:
return None
return code, DITCH_SPLIT_LABEL.get(code, code)
def derived_cells(chainage_m: float, design: dict[str, Any] | None) -> dict[str, float | None]:
"""저장된 횡단 설계 하나에서 **채울 수 있는 칸**만 낸다.
설계가 없거나 설계선이 없으면 ** dict** 낸다 0 으로 때우지 않는다.
도면에 0 찍히면 없다 쟀다 구별할 없다.
"""
if not isinstance(design, dict) or not design:
return {}
cells: dict[str, float | None] = {}
for table_key, design_key in AREA_KEYS:
value = _num(design.get(design_key))
if value is not None:
cells[table_key] = value
# 사면길이는 설계선에서 유도한다 — 설계선이 없으면 유도할 것이 없다.
if not design.get("design_line"):
return cells
slope = station_slope(float(chainage_m), design)
lengths = {
_key(series, face): _length_of(slope, series, face)
for _table_key, series, face in SLOPE_KEYS
}
for table_key, series, face in SLOPE_KEYS:
cells[table_key] = lengths[_key(series, face)]
return cells
@@ -0,0 +1,206 @@
"""표준도 **입력** — 장 하나의 제원을 고쳐 그 조합의 구조물 전부에 반영한다.
여기가 입력 자리인가 (PLAN 4-5b · 2026-09-09)
`phase: "detail"` ( 종류·조달·뒷길이·전면 기울기) **그리는 화면이 없었다**
(2026-09-09 실측: B06 구조물 폼은 b05 phase 칸만 그림). 값들이 표준도가
받는 값이고, ** 하나 = 제원 조합 하나** 여기서 고치면 조합 전부에 걸린다.
B06 폼은 어디에· m(배치), 표준도는 어떤 제원 축이 갈린다.
**자동값을 저장에 박지 않는다.** 칸은 정한 없음 뜻이다. 기울기를 비우면
품셈 표준경사 판정이 돌고, 채우면 값이 이긴다(확정 ). 그래서 값이 오면
**키를 지운다** 0 이나 판정값을 적어 두면 구별이 사라진다.
**막지 않는다.** 실무 도면에 `S0.7`·`0.8` 실재하는데 품셈 표준경사 범위는 0.20~0.50 이다
(2026-09-09 구조물도 53 확인). 범위 밖이면 **안내만** 하고 값은 받는다.
**고치면 장이 갈릴 있다** 조합 전부에 같은 값을 넣으므로 장은 통째로 옮겨 가고
쪼개지지 않는다. 개소만 다르게 하려면 구조물을 따로 고쳐야 하고, 그때 조합이
되어 장이 하나 는다(PLAN 4-5b).
"""
from __future__ import annotations
from typing import Any
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance
#: 표준도에서 받는 칸 — `키 → (이름, 검사)`. 여기 없는 칸은 표준도가 안 만진다.
EDITABLE_KEYS: tuple[str, ...] = (
"stone_kind",
"stone_supply",
"back_len_cm",
"face_slope_ratio",
"foundation",
"stone_coeff_basis",
"fill_concrete_mpa",
)
#: 기초 갈래 — 정본 xls 탭 제목 그대로(`04.구조도(기슭막이).xls`).
#: ⚠ **물량과 그림이 같은 칸을 본다** — 터파기 기초 몫이 0.5×(0.7+0.2)=0.45 대
#: 0.1×(0.7+0.0)=0.07 ㎥/m 로 갈리고, 횡단도 터파기 선도 이 값으로 그려진다.
FOUNDATION_CHOICES: tuple[str, ...] = ("기초유", "기초버림")
#: 품셈 13-4-3·13-4-4 [주]① 의 일곱 규격. 그 밖의 값은 계수가 없어 물량이 안 선다.
BACK_LENGTH_CHOICES: tuple[int, ...] = (25, 30, 35, 45, 55, 60, 75)
#: 품셈 표준경사 표가 덮는 범위. **막는 선이 아니라 안내 선**이다.
SLOPE_TABLE_RANGE: tuple[float, float] = (0.20, 0.50)
#: 안내 문구에 쓸 사람 말.
FIELD_LABELS: dict[str, str] = {
"stone_kind": "돌 종류",
"stone_supply": "조달",
"back_len_cm": "뒷길이",
"face_slope_ratio": "전면 기울기",
"foundation": "기초",
"stone_coeff_basis": "야면석 계수",
"fill_concrete_mpa": "채움 강도",
}
#: 야면석 계수를 어느 열에서 읽나 — 확정 ⑨ 「품셈 열이 기본, 사용자가 고를 수 있게」.
STONE_COEFF_CHOICES: tuple[str, ...] = ("품셈", "실무 관행")
#: 채움 콘크리트 강도(MPa) — 확정 2차 ⑩ 「기본 210, 고를 수 있게」.
#: 180 은 국가기준 하한(돌쌓기 전용), 210 은 콘크리트 구조물 몸체 쪽 기준.
FILL_CONCRETE_CHOICES: tuple[str, ...] = ("180", "210")
def _clean_slope(value: Any) -> tuple[float | None, str | None]:
"""전면 기울기 — `(값, 안내)`. 비면 `(None, None)` 이고 그것이 「자동」의 뜻이다."""
if value in (None, ""):
return None, None
try:
ratio = float(value)
except (TypeError, ValueError):
return None, f"전면 기울기 「{value}」를 숫자로 읽지 못했습니다 — 비워 두면 자동입니다."
if ratio <= 0:
return None, "전면 기울기는 0보다 커야 합니다 — 비워 두면 자동입니다."
low, high = SLOPE_TABLE_RANGE
if not (low <= ratio <= high):
return ratio, (
f"1:{ratio:g} 는 품셈 표준경사 표 범위(1:{low:g}~1:{high:g}) 밖입니다 — "
"실무 도면에 1:0.7·1:0.8 이 실재하므로 값은 그대로 씁니다."
)
return ratio, None
def clean_spec(spec: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
"""받은 제원을 **저장할 꼴**로 다듬는다 — `(고칠 값, 안내 문구)`.
값이 `None`(또는 문자열)이면 ** 키를 지우라** 뜻으로 `None` 담아 돌려준다.
"""
cleaned: dict[str, Any] = {}
notes: list[str] = []
if "stone_kind" in spec:
kind = spec.get("stone_kind")
cleaned["stone_kind"] = str(kind) if kind not in (None, "") else None
if "stone_supply" in spec:
supply = spec.get("stone_supply")
cleaned["stone_supply"] = str(supply) if supply not in (None, "") else None
if "back_len_cm" in spec:
raw = spec.get("back_len_cm")
if raw in (None, ""):
cleaned["back_len_cm"] = None
else:
try:
back = int(float(raw))
except (TypeError, ValueError):
back = None
notes.append(f"뒷길이 「{raw}」를 숫자로 읽지 못했습니다.")
if back is not None:
cleaned["back_len_cm"] = back
if back not in BACK_LENGTH_CHOICES:
notes.append(
f"뒷길이 {back}㎝ 는 품셈 표(25·30·35·45·55·60·75㎝)에 없어 "
"물량이 서지 않습니다."
)
if "foundation" in spec:
found = spec.get("foundation")
cleaned["foundation"] = str(found) if found not in (None, "") else None
if cleaned["foundation"] and cleaned["foundation"] not in FOUNDATION_CHOICES:
notes.append(
f"기초 「{cleaned['foundation']}」는 정본에 없는 갈래입니다 "
f"(있는 것: {' · '.join(FOUNDATION_CHOICES)})."
)
for key, choices in (
("stone_coeff_basis", STONE_COEFF_CHOICES),
("fill_concrete_mpa", FILL_CONCRETE_CHOICES),
):
if key not in spec:
continue
raw = spec.get(key)
cleaned[key] = str(raw) if raw not in (None, "") else None
if cleaned[key] and cleaned[key] not in choices:
notes.append(
f"{FIELD_LABELS[key]}{cleaned[key]}」는 없는 갈래입니다 "
f"(있는 것: {' · '.join(choices)})."
)
if "face_slope_ratio" in spec:
ratio, note = _clean_slope(spec.get("face_slope_ratio"))
cleaned["face_slope_ratio"] = ratio
if note:
notes.append(note)
return cleaned, notes
def drop_unregistered(
type_id: str, spec: dict[str, Any], allowed: set[str]
) -> tuple[dict[str, Any], list[str]]:
"""등록부에 **칸이 없는** 제원은 빼고 알린다 — `(남긴 값, 안내)`.
저장소가 정의되지 않은 옵션 거절하므로, 그대로 넘기면 ** 때문에 전부**
저장이 된다(2026-09-09 실측: `face_slope_ratio` 등록부에 없어 저장 전체 실패).
칸을 받는 것과 아무것도 받는 것은 다르다 나머지는 살리고 ** 받은 칸을
이름으로 말한다**. 조용히 버리면 사용자는 저장된 안다.
"""
kept: dict[str, Any] = {}
notes: list[str] = []
for key, value in spec.items():
if key in allowed:
kept[key] = value
continue
if value is None:
# 지우라는 뜻인데 칸 자체가 없다 — 이미 없으므로 조용히 넘어간다.
continue
notes.append(
f"{FIELD_LABELS.get(key, key)}」 칸이 {type_id} 등록부에 아직 없어 "
"저장하지 못했습니다 — 다른 칸은 저장했습니다."
)
return kept, notes
def apply_spec(
structures: list[StructureInstance], member_ids: set[str], spec: dict[str, Any]
) -> tuple[list[StructureInstance], int]:
"""그 장에 속한 구조물마다 제원을 갈아 끼운다 — `(새 목록, 바뀐 개소 수)`.
**개소 id 고른다** 이름(`sheet_key`) 여기서 다시 셈하지 않는다. 이름은
B08 결과(`height_m` 위로 올라온 ) 위에서 나오는데, 정본
`StructureInstance` 높이가 `options` 안에 있어 **같은 이름이 나온다**.
목록이 이미 `members[].structure_id` 실어 주므로 그것을 그대로 쓴다.
"""
changed = 0
out: list[StructureInstance] = []
for item in structures:
payload = item.model_dump()
if str(payload.get("structure_id") or "") not in member_ids:
out.append(item)
continue
options = dict(payload.get("options") or {})
for key, value in spec.items():
if value is None:
# ⚠ 지운다 — 「정한 적 없음」과 「그 값으로 정함」이 구별돼야 한다.
options.pop(key, None)
else:
options[key] = value
payload["options"] = options
out.append(StructureInstance.model_validate(payload))
changed += 1
return out, changed
@@ -0,0 +1,210 @@
"""표준도(구조물도) 하단표 — **장 나눔**과 **표 조판**.
실무 원본은 울진소광 `07-구조도-소광리.xlsx`(보이는 22 + 숨긴 31). ** 하나 = 하나**이고
안은 치수조서 + 아래 수량산출서. 모듈이 만드는 것은 **아래쪽 **.
실무 표의 짜임(돌쌓기 계열 실측, 2026-09-09)::
(단위 표시) m당
공종 | | 수량 | 단위
1 | 면적 2 × 1 × 1.044 2.088
...
11 | 잔토정리 터파기 되메우기 0.165
찰쌓기는 11, 메쌓기는 8(채움콘크리트·모르터·물구멍이 빠짐)이다. 우리 B08 전개가 이미
줄마다 `name · unit · amount · basis` 내므로 **여기서 새로 계산하지 않는다** 접어서 뿐이다.
** 나눔 축은 제원 조합이다**(2026-09-09 사용자 확정 ). 같은 종류라도 높이·기울기·
뒷길이· 종류가 다르면 그림도 수량도 달라지므로 **장이 갈린다**. 기울기가 제원의 칸이라
기울기가 바뀌면 장이 갈린다 확정이 저절로 지켜진다.
**단위당 ** 성분 수량을 `billing_quantity` 나눈 것이다. 실무 시트 머리의 `m당` ·
`개소당` · `` 단위이고, **구조물마다 다르다**(통일하지 않음 4-1).
"""
from __future__ import annotations
import json
from typing import Any
#: 장을 가르지 **않는** 제원 칸 — 개소마다 다를 뿐 그림·단위수량을 안 바꾼다.
#: 여기 없는 칸은 전부 장 나눔에 들어간다(모르는 칸을 빠뜨려 두 장이 한 장으로 합쳐지는 것보다,
#: 장이 하나 더 서는 쪽이 안전하다 — 합쳐지면 값이 조용히 틀린다).
PER_PLACE_OPTION_KEYS: frozenset[str] = frozenset(
{
"start_m",
"end_m",
"station",
"station_m",
"side",
"length_m",
"note",
"memo",
"label",
}
)
def billing_of(structure: dict[str, Any]) -> tuple[str, float]:
"""이 장이 설 **단위와 그 수량**.
`billing_unit` 비어 있으면 단위가 없다 아니라 **m · 연장**이라는 뜻이다
(`StructureQuantity` 계약). 관측 원단위가 개소당· 종류만 자기 단위를 채운다.
이것을 미정으로 읽으면 **돌쌓기처럼 흔한 종류가 전부 단위 없는 ** 된다.
"""
unit = str(structure.get("billing_unit") or "")
if unit:
return unit, float(structure.get("billing_quantity") or 0.0)
return "m", float(structure.get("length_m") or 0.0)
def _sheet_options(structure: dict[str, Any]) -> dict[str, Any]:
"""장 나눔에 쓰는 제원만 남긴다."""
options = structure.get("options") or {}
return {
key: value for key, value in sorted(options.items()) if key not in PER_PLACE_OPTION_KEYS
}
def sheet_key(structure: dict[str, Any]) -> str:
"""제원 조합 하나를 가리키는 이름. 같은 값이면 같은 장이다."""
payload = {
"type_id": structure.get("type_id") or "",
"height_m": round(float(structure.get("height_m") or 0.0), 3),
"options": _sheet_options(structure),
}
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 (
FIGURE_TYPE_IDS,
slope_of,
)
if str(structure.get("type_id") or "") not in FIGURE_TYPE_IDS:
return None
return slope_of(structure)[0]
def sheet_title(structure: dict[str, Any]) -> str:
"""장 제목 — 「이름 H=2.0 1:0.3 뒷길이 45㎝」처럼 **무엇이 갈랐는지**가 보이게."""
parts = [str(structure.get("name") or structure.get("type_id") or "구조물")]
height = float(structure.get("height_m") or 0.0)
if height > 0:
parts.append(f"H={height:g}")
options = structure.get("options") or {}
# 기울기는 **판정 결과**를 적는다 — 사용자가 안 정해도 품셈 표준경사로 갈린다(확정 ⑨).
# 판정이 안 되는 종류(옹벽 등)는 안 적는다.
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")
if back:
parts.append(f"뒷길이 {int(back)}")
kind = options.get("stone_kind")
if kind:
parts.append(str(kind))
return " ".join(parts)
def _unit_amount(amount: float, quantity: float) -> float | None:
"""단위당 값. 셀 단위를 못 정했으면 **0 으로 나누지 않고 `None`** 을 낸다."""
if quantity <= 0:
return None
return amount / quantity
def _rows_of(structure: dict[str, Any]) -> list[dict[str, Any]]:
"""구조물 하나의 성분을 표 줄로 접는다 — 실무 시트의 `공종 | 산출근거 | 수량 | 단위`."""
_unit, quantity = billing_of(structure)
rows: list[dict[str, Any]] = []
for index, component in enumerate(structure.get("components") or [], start=1):
amount = float(component.get("amount") or 0.0)
rows.append(
{
"no": index,
"name": component.get("name") or "",
# 실무 시트의 「산출근거」 칸 — B08 이 이미 사람이 읽는 문구로 낸다.
"basis": component.get("basis") or "",
"unit_amount": _unit_amount(amount, quantity),
"amount": amount,
"unit": component.get("unit") or "",
# 값이 식에서 나왔나(derived) 관측 원단위표에서 왔나(observed) — 되짚기용.
"basis_kind": component.get("basis_kind") or "",
"source": component.get("source") or "",
}
)
return rows
def build_standard_sheets(
unit_table: dict[str, Any], section_modes: dict[float, str] | None = None
) -> dict[str, Any]:
"""B08 원단위 전개(`build_unit_table` 결과)를 **표준도 장 목록**으로 접는다.
계산을 다시 하지 않는다 들어온 전개를 제원 조합으로 묶고 단위당으로 나눌 뿐이다.
"""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import _section_mode_at
from common_util.common_util_structure_face_role import structure_face_role
groups: dict[str, dict[str, Any]] = {}
for structure in unit_table.get("structures") or []:
# ⚠ 성절토는 **표를 만든 그 판정**을 그대로 물고 온다 — 제목·그림이 다시 판정하면
# 근거를 못 받아 종전값으로 떨어져 **표와 갈린다**(2026-09-09 실측: 표 1:0.35 ·
# 제목 1:0.3). 자리를 고르는 것도 B08 이 쓰는 그 함수를 쓴다.
mode = _section_mode_at(structure, section_modes)
face, face_reason = structure_face_role(mode, (structure.get("options") or {}).get("side"))
structure = {**structure, "face": face, "face_reason": face_reason}
key = sheet_key(structure)
sheet = groups.get(key)
if sheet is None:
unit, _quantity = billing_of(structure)
sheet = {
"key": key,
"title": sheet_title(structure),
"type_id": structure.get("type_id") or "",
"height_m": float(structure.get("height_m") or 0.0),
"options": _sheet_options(structure),
# 판정 근거 — 제목·그림이 표와 **같은 기울기**를 쓰게 하는 열쇠.
"face": structure.get("face"),
"face_reason": structure.get("face_reason") or "",
# 실무 시트 머리의 「m당」·「개소당」·「㎡당」. 종류마다 다르다 — 통일하지 않는다.
"unit_label": f"{unit}",
"billing_unit": unit,
"rows": _rows_of(structure),
"members": [],
"notes": list(structure.get("notes") or []),
}
groups[key] = sheet
sheet["members"].append(
{
"structure_id": structure.get("structure_id"),
"name": structure.get("name") or "",
"start_m": structure.get("start_m"),
"end_m": structure.get("end_m"),
"length_m": float(structure.get("length_m") or 0.0),
"billing_quantity": billing_of(structure)[1],
}
)
for note in structure.get("notes") or []:
if note not in sheet["notes"]:
sheet["notes"].append(note)
sheets = sorted(groups.values(), key=lambda sheet: (sheet["type_id"], sheet["height_m"]))
for sheet in sheets:
sheet["member_count"] = len(sheet["members"])
sheet["billing_total"] = sum(member["billing_quantity"] for member in sheet["members"])
# ⚠ 단위당을 못 낸 줄 — 「값이 없다」를 숫자 0 으로 때우지 않고 이름으로 드러낸다.
sheet["unpriced_rows"] = [
row["name"] for row in sheet["rows"] if row["unit_amount"] is None
]
return {
"sheets": sheets,
"sheet_count": len(sheets),
# 왜 안 실렸는지 — 화면이 「구조물이 없다」와 「걸러졌다」를 가릴 수 있어야 한다.
"structure_count": int(unit_table.get("structure_count") or 0),
"pending_choices": list(unit_table.get("pending_choices") or []),
}
+42 -11
View File
@@ -8,6 +8,7 @@ from pathlib import Path, PurePosixPath
from typing import Any
from uuid import UUID
from aiomysql import DictCursor
from fastapi import APIRouter
from fastapi.responses import JSONResponse
@@ -30,6 +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 B07_DesignDetail.B07_DesignDetail_Router_Support import (
CROSS_STANDARD_ID,
LANDUSE_ID,
@@ -56,6 +58,7 @@ from B07_DesignDetail.B07_DesignDetail_Schema import (
DesignDrawingListResponse,
DesignDrawingResponse,
)
from common_util.common_util_dev_unlock import is_dev_environment, unlock_status
from common_util.common_util_drainage_context import load_drainage_context
from common_util.common_util_storage import read_stored_asset, resolve_stored_project_path
from common_util.common_util_workflow_state import complete_stage, start_stage
@@ -68,8 +71,21 @@ _CROSS_ID = re.compile(r"^cross_(\d+)m$")
_LONG_ID = re.compile(r"^longitudinal(?:_(\d+))?$")
async def _confirmed_source(project_id: UUID) -> tuple[int, Path, Path]:
"""확정된 B06 종단 레코드와 프로젝트 저장 경로를 반환한다."""
#: B07(상세설계) 워크플로 단계 번호 — 개발 우회로가 이 단계를 풀었는지 본다.
_B07_STAGE_NO = 4
async def _confirmed_source(project_id: UUID) -> tuple[int, Path, Path, bool]:
"""확정된 B06 종단 레코드와 프로젝트 저장 경로. 넷째는 **개발 우회로로 열렸나**.
**문은 서버가 막는다.** 화면 표시로 여는 것이 아니라 여기서 막고, **운영에서는 그대로
409** (`is_dev_environment()` 거짓이면 우회가 아예 없다).
**개발환경이라고 무조건 열지 않는다** `dev/unlock` 표식이 실제로 있을 때만 연다.
그래야 되돌리기(DELETE) **다시 닫힌다**.
**레코드는 손대지 않는다** `longitudinal_sections.status` `DRAFT` 그대로 두고
**읽는 쪽만** 우회한다. 상태를 올리면 그것이 확정 흉내 `common_util_dev_unlock`
스스로 금지한 자리와 같아진다.
"""
pool = get_db_pool()
async with pool.acquire() as connection:
route_context = await get_confirmed_route_context(connection, project_id)
@@ -77,15 +93,22 @@ async def _confirmed_source(project_id: UUID) -> tuple[int, Path, Path]:
raise FileNotFoundError("확정된 경로가 없습니다.")
route_id = int(route_context["route_id"])
longitudinal = await get_longitudinal_section(connection, project_id, route_id)
bypassed = False
if not longitudinal or longitudinal.get("status") != "CONFIRMED":
raise PermissionError("B06 종·횡단 확정 후 상세 설계를 진행할 수 있습니다.")
if is_dev_environment():
async with connection.cursor(DictCursor) as cursor:
status = await unlock_status(cursor, str(project_id))
bypassed = _B07_STAGE_NO in (status.get("bypassed_stages") or [])
if not (bypassed and longitudinal):
raise PermissionError("B06 종·횡단 확정 후 상세 설계를 진행할 수 있습니다.")
logger.info("B07 개발 우회로로 열림 — 확정 건너뜀: project_id=%s", project_id)
stored_path = await get_project_storage_relative_path(connection, project_id)
root = Path(resolve_stored_project_path(stored_path)).resolve()
longitudinal_path = (root / str(longitudinal["longitudinal_file_path"])).resolve()
if root not in longitudinal_path.parents or not longitudinal_path.is_file():
raise FileNotFoundError("B06 종단면 파일을 찾을 수 없습니다.")
return route_id, root, longitudinal_path
return route_id, root, longitudinal_path, bypassed
async def _company_dir(project_id: UUID) -> Path:
@@ -246,11 +269,14 @@ async def get_design_drawing_list(
) -> DesignDrawingListResponse | JSONResponse:
"""B07 좌측 패널용 도면 메타데이터만 캐시한다."""
try:
route_id, project_root, longitudinal_path = await _confirmed_source(project_id)
route_id, project_root, longitudinal_path, bypass = await _confirmed_source(project_id)
designs = await _designs_by_chainage(route_id)
drawings = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path, designs)
modes = await section_modes_of(route_id)
drawings = await asyncio.to_thread(
_drawing_list, project_root, longitudinal_path, designs, modes
)
return DesignDrawingListResponse(
project_id=str(project_id), route_id=route_id, drawings=drawings
project_id=str(project_id), route_id=route_id, drawings=drawings, dev_bypass=bypass
)
except FileNotFoundError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
@@ -270,7 +296,7 @@ async def get_design_drawing(
) -> DesignDrawingResponse | JSONResponse:
"""선택한 도면 원본 한 건만 읽어 CAD 스키마로 변환한다."""
try:
route_id, project_root, longitudinal_path = await _confirmed_source(project_id)
route_id, project_root, longitudinal_path, bypass = await _confirmed_source(project_id)
# 이 회사가 고친 도각이 있으면 그것으로 그린다(없으면 프로그램 기본 도각).
# 저장 경로는 `storage/{회사}/{사용자}/{프로젝트}` 이므로 두 단계 위가 회사 폴더다.
use_company_templates(project_root.parent.parent)
@@ -341,7 +367,12 @@ async def get_design_drawing(
longitudinal = await asyncio.to_thread(_read_json, longitudinal_path)
source_design = await asyncio.to_thread(plan_source, context, longitudinal, drawing_id)
kind, label, drawing, confirmed, quantity_table = await asyncio.to_thread(
_read_drawing, project_root, longitudinal_path, drawing_id, source_design
_read_drawing,
project_root,
longitudinal_path,
drawing_id,
source_design,
await section_modes_of(route_id),
)
return DesignDrawingResponse(
project_id=str(project_id),
@@ -382,7 +413,7 @@ async def confirm_design_drawing(
횡단도 확정 B06 지정 잠정치를 동일 엔진으로 재계산해 확정치로 승격·저장한다.
"""
try:
route_id, project_root, longitudinal_path = await _confirmed_source(project_id)
route_id, project_root, longitudinal_path, bypass = await _confirmed_source(project_id)
designs = await _designs_by_chainage(route_id)
items = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path, designs)
item = next((candidate for candidate in items if candidate.id == drawing_id), None)
@@ -501,7 +532,7 @@ async def invalidate_design_drawing(
) -> DesignDrawingInvalidateResponse | JSONResponse:
"""확정 도면 편집 시 B07 및 이후 단계를 미확정 상태로 되돌린다."""
try:
route_id, project_root, longitudinal_path = await _confirmed_source(project_id)
route_id, project_root, longitudinal_path, bypass = await _confirmed_source(project_id)
# 목록은 **설계값을 넣어** 만든다 — 장 나눔이 측점 표시 폭에 따라 달라지므로,
# 설계 없이 만들면 방금 확정한 장 id 가 목록에 없어 [수정]이 404 로 막힌다
# (2026-09-03 실측: `cross_s00220m` 확정 후 확정 해제 불가).
@@ -0,0 +1,193 @@
"""B07 표준도(구조물도) 라우터 — 장 목록 조회와 **제원 입력**.
표준도는 도면이자 **입력 화면**이다(PLAN 4-5b). `phase: "detail"` ( 종류·조달·뒷길이·
전면 기울기) 그리는 화면이 없어(2026-09-09 실측) 자리를 여기가 맡는다.
** 하나 = 제원 조합 하나** 고치면 조합의 개소 전부에 걸린다.
값을 여기서 셈하지 않는다 정본(`structures.json`) 적기만 하고 ·그림은 다음 조회에서
정본으로 다시 선다. 표준도가 번째 정본이 되면 된다(CLAUDE.md 5).
"""
import asyncio
import logging
from pathlib import Path
from uuid import UUID
from fastapi import APIRouter
from fastapi.responses import JSONResponse
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
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"])
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
try:
context = await run_with_connection(get_workflow_route_context, project_id)
route_id = int((context or {}).get("route_id") or 0)
except Exception:
logger.exception("B07 노선 조회 실패: project_id=%s", project_id)
return {}
return await section_modes_of(route_id) if route_id else {}
async def section_modes_of(route_id: int) -> dict[float, str]:
"""측점별 단면유형(`left_cut` 등) — 구조물이 **성토면인가 절토면인가**를 가르는 근거.
이것을 넘기면 판정이 통째로 가를 근거 없음으로 떨어져 ** 구조물이 종전값
1:0.3 으로 선다**(2026-09-09 실측). 값이 없는 것이 아니라 ** 넘긴 **이었다.
표를 만드는 셈은 `section_modes_from_designs` 벌을 쓴다 부르는 쪽마다 다시
짜면 B08 갈린다.
"""
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)
return {}
return section_modes_from_designs(designs)
class StandardSheetSpecRequest(BaseModel):
"""표준도 장 하나의 제원. **빈 값(null)은 「정한 적 없음」**이라 그 칸을 지운다."""
model_config = ConfigDict(extra="forbid")
sheet_key: str
base_revision: int = Field(ge=0)
stone_kind: str | None = None
stone_supply: str | None = None
back_len_cm: int | str | None = None
face_slope_ratio: float | str | None = None
foundation: str | None = None
stone_coeff_basis: str | None = None
fill_concrete_mpa: str | None = None
@router.put("/{project_id}/standard-sheets/spec")
async def put_standard_sheet_spec(
project_id: UUID, payload: StandardSheetSpecRequest
) -> JSONResponse:
"""장 하나의 제원을 고쳐 **그 조합의 구조물 전부**에 반영한다.
값을 여기서 셈하지 않는다 정본(`structures.json`) 적기만 하고, ·그림은 다음
조회에서 정본으로 다시 선다. 표준도가 번째 정본이 되면 된다(CLAUDE.md 5).
"""
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 (
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": "프로젝트 저장 폴더를 찾지 못했습니다."},
)
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 []
picked = next((s for s in sheets if s.get("key") == payload.sheet_key), None)
if picked is None:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "그 표준도 장을 찾지 못했습니다."},
)
member_ids = {
str(m.get("structure_id")) for m in picked.get("members") or [] if m.get("structure_id")
}
spec, notes = clean_spec(payload.model_dump(exclude={"sheet_key", "base_revision"}))
# ⚠ 등록부에 없는 칸은 저장소가 거절한다 — 한 칸 때문에 **전부** 못 저장되지 않게 거른다.
type_id = str(picked.get("type_id") or "")
definition = structure_type_map().get(type_id)
allowed = {field.key for field in definition.options} if definition else set()
spec, missing = drop_unregistered(type_id, spec, allowed)
notes.extend(missing)
try:
revision, stored = await asyncio.to_thread(load_structures, str(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
)
except Exception as exc:
logger.exception("B07 표준도 제원 저장 실패: project_id=%s", project_id)
return JSONResponse(
status_code=409,
content={"status": "error", "message": f"제원을 저장하지 못했습니다 — {exc}"},
)
return JSONResponse(
content={
"status": "success",
"project_id": str(project_id),
"revision": new_revision,
"previous_revision": revision,
"changed": changed,
# 범위 밖 값·표에 없는 규격은 **막지 않고 알린다**(실무에 1:0.7 이 실재).
"notes": notes,
}
)
@router.get("/{project_id}/standard-sheets")
async def get_standard_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
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())
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)
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)
@@ -17,10 +17,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
station_no_label,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import build_watershed_drawing
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Cover import (
build_blank_drawing,
build_cover_drawing,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Cover import build_cover_drawing
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Landuse import (
LANDUSE_LABEL,
build_landuse_drawing,
@@ -51,9 +48,15 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Standard import (
STANDARD_LABEL,
build_standard_cross_drawing,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardSheet import (
SHEET_ID_PREFIX,
sheet_items,
standard_drawing_for,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import (
QUANTITY_VALUE_KEYS,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cross_Quantity import derived_cells
from B07_DesignDetail.B07_DesignDetail_Engine_Template import add_title_fields
# 유역도 배경·파일 입출력 조각은 700줄 제한으로 떼어냈다(2026-09-04).
@@ -105,16 +108,12 @@ COVER_ID = "cover"
# 표준 횡단면도 — 노선 자료가 아니라 B06 표준 횡단면 설정값으로 그리는 한 장.
CROSS_STANDARD_ID = "cross_standard"
# 아직 내용을 만들지 않은 도면 — 도각만 실어 연다(2026-09-01 사용자 지시).
# 화면 순서(DRAWING_GROUPS)와 같은 이름을 쓴다.
BLANK_DRAWINGS: tuple[tuple[str, str], ...] = (("blank_standard", "표준도"),)
BLANK_LABELS: dict[str, str] = dict(BLANK_DRAWINGS)
def _drawing_list(
project_root: Path,
longitudinal_path: Path,
designs: dict[int, dict[str, Any]] | None = None,
section_modes: dict[float, str] | None = None,
) -> list[DesignDrawingItem]:
longitudinal = _read_json(longitudinal_path)
station_by_chainage = _station_map(longitudinal)
@@ -206,10 +205,10 @@ def _drawing_list(
confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")),
)
)
# 아직 내용을 만들지 않은 도면도 **빈 도각으로 열린다**(2026-09-01 사용자 지시).
# 목록의 절반이 눌리지 않는 회색 버튼이면 고장난 것처럼 보다.
for drawing_id, label in BLANK_DRAWINGS:
drawings.append(DesignDrawingItem(id=drawing_id, kind="blank", label=label))
# 표준도 — **제원 조합마다 한 장**이라 장이 나뉜다(2026-09-09). 구조물이 없어도 한 장은
# 남긴다: 단추가 사라지면 「없어진처럼 보이고 사유를 읽을 자리도 없어진다.
for drawing_id, label in sheet_items(project_root, section_modes):
drawings.append(DesignDrawingItem(id=drawing_id, kind="standard", label=label))
_store_drawing_numbers(project_root, drawings)
return drawings
@@ -270,6 +269,11 @@ def _quantity_table(
**계획고는 횡단 설계(design)에도 있다** 배치 입력의 원본에는 값이 없어
계획고·절토고·성토고 칸이 통째로 비어 나갔다(2026-09-03 실측: 확정 21
항목 지반고 하나만 채워짐). 원본에 없으면 설계에서 읽는다.
**본문 칸도 설계에서 온다**(2026-09-09) `source["quantities"]` 키는 원본 파일에
아예 없어 열일곱 칸이 통째로 비어 나갔다. 채울 있는 것은 `_Engine_Cross_Quantity`
낸다(단면적은 저장값, 사면 계열은 B08 쓰는 함수 그대로). 별표2 법정 요구
여덟 넷이 비던 자리다.
"""
def num(value: Any) -> float | None:
@@ -289,6 +293,8 @@ def _quantity_table(
"cut": cut,
"fill": fill,
}
chainage = num(source.get("chainage_m")) or 0.0
table.update(derived_cells(chainage, design))
for key in QUANTITY_VALUE_KEYS:
table.setdefault(key, num(quantities.get(key)))
return table
@@ -404,6 +410,7 @@ def _read_drawing(
longitudinal_path: Path,
drawing_id: str,
stored_design: dict[str, Any] | None = None,
section_modes: dict[float, str] | None = None,
) -> tuple[str, str, dict[str, Any], bool, dict[str, float | None] | None]:
"""(kind, label, drawing, confirmed, quantity_table)를 반환한다.
@@ -435,10 +442,11 @@ def _read_drawing(
stored_table = manifest_entry.get("quantity_table")
table = stored_table if kind == "cross" and isinstance(stored_table, dict) else None
return kind, label, saved, True, table
if drawing_id in BLANK_LABELS:
# 아직 내용을 만들지 않은 도면 — 도각만 실어 연다. 확정 대상이 아니다.
label = BLANK_LABELS[drawing_id]
return "blank", label, build_blank_drawing(drawing_id, label), False, None
if drawing_id == SHEET_ID_PREFIX or drawing_id.startswith(f"{SHEET_ID_PREFIX}_"):
# 표준도 — 제원 조합 한 벌이 한 장. 도각은 두르지 않는다(2026-09-08 사용자 지시).
label = dict(sheet_items(project_root, section_modes)).get(drawing_id, "표준도")
drawing = standard_drawing_for(project_root, drawing_id, label, section_modes)
return "standard", label, drawing, False, None
if drawing_id == COVER_ID:
# 표지는 설계 자료를 쓰지 않는다 — 템플릿 한 장이 곧 도면이다.
@@ -23,18 +23,9 @@ MASS_HAUL_ID = "mass_haul"
WATERSHED_ID = "watershed"
COVER_ID = "cover"
# 아직 내용을 만들지 않은 도면 — 도각만 실어 연다(2026-09-01 사용자 지시).
# 화면 순서(DRAWING_GROUPS)와 같은 이름을 쓴다.
BLANK_DRAWINGS: tuple[tuple[str, str], ...] = (
("blank_plan_terrain", "계획평면도(지형)"),
("blank_plan_route", "계획평면도(노선배치도)"),
("blank_plan_layout", "계획평면도(배치도)"),
("blank_plan_lidar", "계획평면도(라이다)"),
("blank_cross_standard", "표준 횡단면도"),
("blank_standard", "표준도"),
("blank_landuse", "용지도"),
)
BLANK_LABELS: dict[str, str] = dict(BLANK_DRAWINGS)
# ⚠ 빈-도각 목록이 여기 있었다 — 계획평면도·표준 횡단면도·용지도·표준도 일곱 줄.
# 2026-09-09 지웠다: 일곱 **전부 실제 도면이 되어** 아무도 이 목록을 import 하지 않았다
# (표준도가 마지막이었다). 남겨 두면 「계획평면도가 빈 도각으로 열리나?」로 읽히는 덫이다.
def _read_json(path: Path) -> dict[str, Any]:
@@ -20,6 +20,8 @@ class DesignDrawingItem(BaseModel):
"landuse",
"plan_lidar",
"cross_standard",
# standard: 표준도(구조물도) — 제원 조합 하나가 한 장.
"standard",
"blank",
]
label: str
@@ -34,6 +36,9 @@ class DesignDrawingListResponse(BaseModel):
project_id: str
route_id: int
drawings: list[DesignDrawingItem]
#: 확정을 건너뛰고 개발 우회로로 열렸나. **조용히 열지 않기 위해** 응답에 싣는다 —
#: 그냥 열면 다음 사람이 「왜 값이 없나」로 헤맨다(2026-09-09).
dev_bypass: bool = False
class DesignDrawingResponse(BaseModel):
@@ -54,6 +59,8 @@ class DesignDrawingResponse(BaseModel):
"landuse",
"plan_lidar",
"cross_standard",
# standard: 표준도(구조물도) — 제원 조합 하나가 한 장.
"standard",
"blank",
]
label: str
+139 -13
View File
@@ -38,14 +38,22 @@ import {
exportDrawing,
fetchDesignDrawing,
fetchDesignDrawingList,
fetchStandardSheets,
fetchStructureRevision,
invalidateDesignDrawing,
putStandardSheetSpec,
type CadDrawing,
type DesignDrawingItem,
type DesignDrawingResponse,
type QuantityTable,
type StandardSheetsResponse,
} from "./B07_DesignDetail_Api_Fetch";
import { appendStructureEntities } from "./B07_DesignDetail_UI_Cad_Structures";
import { createFrameTemplateEditor } from "./B07_DesignDetail_UI_FrameEdit";
import {
buildStandardSpecPanel,
type StandardSpecResult,
} from "./B07_DesignDetail_UI_StandardSpec";
/** CAD 앱 수량 패널로 넘기는 설계 컨텍스트 (openwebcad DesignMeta와 동일 형식). */
interface DesignMeta {
@@ -94,17 +102,32 @@ const CAD_TOAST_ACTION_MESSAGE = "aislo:b08:toast-action";
* -------------------------------------------------------------------------- */
export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
/** 표준도 장 목록 — 제원 폼이 쓰는 값. 진입 때 한 번 받고 저장 뒤 다시 받는다. */
let standardSheets: StandardSheetsResponse["sheets"] = [];
/** 저장 뒤 폼이 다시 그려질 때 이어서 보여 줄 안내. */
let specNotes: string[] = [];
let workflowState: WorkflowState | undefined;
let drawings: DesignDrawingItem[] = [];
let drawingError: string | undefined;
let devBypass = false;
if (projectId) {
const [workflowResult, drawingResult] = await Promise.allSettled([
fetchWorkflowState(projectId),
fetchDesignDrawingList(projectId),
]);
if (workflowResult.status === "fulfilled") workflowState = workflowResult.value;
if (drawingResult.status === "fulfilled") drawings = drawingResult.value.drawings;
else
if (drawingResult.status === "fulfilled") {
drawings = drawingResult.value.drawings;
devBypass = drawingResult.value.dev_bypass === true;
if (drawings.some((item) => item.kind === "standard") && projectId) {
// 표준도 제원 폼이 쓸 장 목록 — 실패해도 도면은 열려야 하므로 조용히 넘어간다.
try {
standardSheets = (await fetchStandardSheets(projectId)).sheets;
} catch {
standardSheets = [];
}
}
} else
drawingError =
drawingResult.reason instanceof Error
? drawingResult.reason.message
@@ -134,6 +157,50 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
frameFields: Record<string, string>;
}
| undefined;
// ⚠ CAD 는 iframe 이라 **저쪽이 아무 말도 안 하면 화면이 영원히 「불러오는 중」에 머문다**
// (2026-09-09 사용자 보고 — 무한 로딩). 끝을 알리는 것은 `drawing-loaded` ·
// `drawing-error` 두 통지뿐이고, 그것이 안 오는 길이 둘 있다.
// ① iframe 이 아예 안 뜸 — `dist/` 가 없거나 스크립트가 죽음 ⇒ `ready` 가 안 옴
// ② 떴는데 도면을 여는 중에 멈춤 ⇒ `loaded` 도 `error` 도 안 옴
// 아래 시계가 그 자리를 끊는다. ⚠ **화면 표시만 끊는다** — 뒤늦게 응답이 오면
// 그대로 받아 정상으로 되돌아간다(요청을 취소하지 않는다).
const CAD_READY_TIMEOUT_MS = 20000;
const CAD_LOAD_TIMEOUT_MS = 15000;
let loadWatchdog: number | undefined;
const failCad = (detail: string): void => {
cadHost.dataset.loading = "false";
cadHost.dataset.error = detail;
showToast(detail, "error");
};
const stopLoadWatchdog = (): void => {
if (loadWatchdog === undefined) return;
window.clearTimeout(loadWatchdog);
loadWatchdog = undefined;
};
const startLoadWatchdog = (): void => {
stopLoadWatchdog();
// 아직 `ready` 를 못 받았으면 iframe 이 뜨기를 기다리는 중이라 더 길게 준다.
const wait = cadReady ? CAD_LOAD_TIMEOUT_MS : CAD_READY_TIMEOUT_MS;
loadWatchdog = window.setTimeout(() => {
loadWatchdog = undefined;
if (cadHost.dataset.loading !== "true") return;
failCad(
cadReady
? "CAD 가 도면을 여는 데 너무 오래 걸립니다. 다시 눌러 보세요."
: "CAD 화면이 응답하지 않습니다. 새로고침해도 같으면 CAD 빌드(dist)를 확인하세요.",
);
}, wait);
};
// iframe 자체가 못 뜨는 경우 — 이때는 `ready` 가 영영 안 오므로 기다릴 것 없이 끊는다.
frame.addEventListener("error", () => {
stopLoadWatchdog();
failCad("CAD 화면을 불러오지 못했습니다. CAD 빌드(dist)를 확인하세요.");
});
let currentDrawing: DesignDrawingItem | undefined;
let currentIndex = -1;
let currentConfirmed = false;
@@ -146,7 +213,52 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
const infoPanelHost = document.createElement("div");
infoPanelHost.className = "b07-info-host";
const showStandardSpec = (drawing: DesignDrawingItem): void => {
const index = Number(/_(\d+)$/.exec(drawing.id)?.[1] ?? "1") - 1;
const sheet = standardSheets[index];
if (!projectId || !sheet) {
infoPanelHost.replaceChildren();
return;
}
// 판정된 기울기는 **칸에 적지 않고 도움말로만** 비춘다 — 적어 두면 「안 정함」이 사라진다.
const judged = /1:([\d.]+)/.exec(drawing.label)?.[1] ?? null;
infoPanelHost.replaceChildren(
buildStandardSpecPanel(
sheet,
judged,
async (result: StandardSpecResult) => {
const { revision } = await fetchStructureRevision(projectId);
const saved = await putStandardSheetSpec(projectId, {
...result,
base_revision: revision,
});
// 정본이 바뀌었으니 표·그림을 **다시 받아** 그린다 — 화면이 두 번째 정본이 되면 안 된다.
drawingCache.delete(drawing.id);
standardSheets = (await fetchStandardSheets(projectId)).sheets;
// ⚠ 장 제목에 제원이 들어 있다 — 고쳤으면 **좌측 단추 글자도 따라가야** 한다.
// 목록을 통째로 다시 받지 않고 표준도 줄만 갈아 끼운다(2026-09-09 실화면에서 잡음).
for (const [order, item] of drawings.filter((d) => d.kind === "standard").entries()) {
const fresh = standardSheets[order];
if (!fresh) continue;
item.label = `표준도 ${order + 1}장 (${fresh.title})`;
const name = findButton(item.id)?.querySelector(".b07-drawing-button__name");
if (name) name.textContent = item.label;
}
specNotes = [`${saved.changed}개소에 반영했습니다.`, ...(saved.notes ?? [])];
await loadDrawing(drawing, currentIndex);
return specNotes;
},
specNotes,
),
);
specNotes = [];
};
const updateInfoPanel = (drawing: DesignDrawingItem, response: DesignDrawingResponse): void => {
if (drawing.kind === "standard") {
showStandardSpec(drawing);
return;
}
if (drawing.kind !== "cross") {
infoPanelHost.replaceChildren();
return;
@@ -261,10 +373,19 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
return request;
};
/** 목록 전체를 순서대로 미리 받는다. 화면을 막지 않도록 한 번에 하나씩만 간다. */
/** . .
*
* ** ** (2026-09-09 ).
* 31 0.3~1.0,
* ** ** 30 . .
* ** ** , .
*/
const prefetchAllDrawings = async (): Promise<void> => {
if (!projectId) return;
for (const item of drawings) {
// 클릭이 도는 중이면 끝날 때까지 기다린다 — 미리 받기는 급하지 않다.
while (loadInFlight) await new Promise((done) => window.setTimeout(done, 120));
if (drawingCache.has(item.id)) continue; // 클릭이 이미 받아 둔 장은 건너뛴다
try {
await requestDrawing(item);
} catch {
@@ -294,6 +415,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
if (button) button.dataset.loading = "true";
cadHost.dataset.loading = "true";
cadHost.dataset.error = ""; // 앞선 실패 표시를 지운다
startLoadWatchdog(); // 저쪽이 말이 없으면 여기서 끊는다
try {
const response = await requestDrawing(drawing);
currentDrawing = drawing;
@@ -304,10 +426,8 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
updateInfoPanel(drawing, response);
sendLoad(response.drawing, buildMeta(drawing, response, index));
} catch (error) {
cadHost.dataset.loading = "false";
cadHost.dataset.error =
error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다.";
showToast(cadHost.dataset.error, "error");
stopLoadWatchdog(); // 여기서 이미 끝났다 — 시계를 두면 늦게 또 오류를 띄운다
failCad(error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다.");
if (currentDrawing) highlightActive(currentDrawing.id);
} finally {
loadInFlight = false;
@@ -493,19 +613,22 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
);
} else if (message.type === CAD_READY_MESSAGE) {
cadReady = true;
if (pendingLoad)
if (pendingLoad) {
sendLoad(
pendingLoad.drawing,
pendingLoad.meta,
pendingLoad.frameEdit,
pendingLoad.frameFields,
);
// 기다리던 것이 「iframe 이 뜨기」에서 「도면이 열리기」로 바뀌었다 — 시계를 다시 건다.
if (cadHost.dataset.loading === "true") startLoadWatchdog();
}
} else if (message.type === CAD_LOADED_MESSAGE) {
stopLoadWatchdog();
cadHost.dataset.loading = "false";
} else if (message.type === CAD_ERROR_MESSAGE) {
cadHost.dataset.error = message.detail ?? "CAD 도면을 표시하지 못했습니다.";
cadHost.dataset.loading = "false";
showToast(cadHost.dataset.error, "error");
stopLoadWatchdog();
failCad(message.detail ?? "CAD 도면을 표시하지 못했습니다.");
} else if (message.type === CAD_CHANGED_MESSAGE) {
// 편집 통지는 **미저장 표시**만 세운다. 확정을 푸는 것은 [수정] 하나뿐이다
// (2026-09-01 사용자 확정) — 예전에는 이 통지가 확정을 풀어, 되돌리기나 색
@@ -525,7 +648,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
}
});
const drawingPanel = buildDrawingSidePanel(drawings, selectDrawing, drawingError);
const drawingPanel = buildDrawingSidePanel(drawings, selectDrawing, drawingError, devBypass);
drawingListEl = drawingPanel;
const confirmActions = document.createElement("div");
// 하단 고정은 공용 ui-sidebar-actions로 통일 — 사이드 본문이 [스크롤 영역][액션 줄]로
@@ -551,7 +674,10 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
routes: WORKFLOW_STEP_ROUTES,
onStepClick: (stepIndex) => {
if (!projectId) return;
if (stepIndex > 5 && !allDrawingsConfirmed) {
// ⚠ 개발에서는 막지 않는다 (2026-09-09 사용자 지시) — B08·B09 를 확정 전에 봐야
// 화면 검증이 되고, 그 우회 단추가 **B08 안에** 있어 여기서 막히면 닿을 길이 없다.
// 운영에서는 그대로 막는다.
if (stepIndex > 5 && !allDrawingsConfirmed && !import.meta.env.DEV) {
showToast("모든 설계 도면을 확정한 뒤 다음 단계로 이동할 수 있습니다.", "warning");
return;
}
+11 -1
View File
@@ -37,7 +37,8 @@ export const DRAWING_GROUPS: readonly {
{ label: "횡단면도", kind: "cross" },
{ label: "토적도(유토곡선)", kind: "mass_haul" },
{ label: "유역도(배수 유역도)", kind: "watershed" },
{ label: "표준도", blankId: "blank_standard" },
// 표준도는 **제원 조합마다 한 장**이라 장이 나뉜다(2026-09-09) — 계획평면도와 같은 묶음.
{ label: "표준도", idPrefix: "standard_sheet" },
{ label: "용지도", idPrefix: "landuse" },
];
@@ -46,6 +47,7 @@ export function buildDrawingSidePanel(
drawings: DesignDrawingItem[],
onSelect: (drawing: DesignDrawingItem) => void,
errorMessage?: string,
devBypass = false,
): HTMLDivElement {
const panel = document.createElement("div");
panel.className = "b07-drawing-list";
@@ -57,6 +59,14 @@ export function buildDrawingSidePanel(
count.textContent = `${drawings.length}`;
heading.append(title, count);
panel.append(heading);
// 확정을 건너뛴 상태라는 것을 **조용히 두지 않는다** — 그냥 열면 다음 사람이
// 「왜 값이 없나」로 헤맨다(2026-09-09). 되돌리기는 B08 좌측 [확정 없이 다음으로] 옆.
if (devBypass) {
const notice = document.createElement("p");
notice.className = "b07-drawing-list__bypass";
notice.textContent = "확정을 건너뛴 상태입니다 — 개발 전용. 값이 비어 보일 수 있습니다.";
panel.append(notice);
}
if (errorMessage || drawings.length === 0) {
const empty = document.createElement("p");
@@ -0,0 +1,192 @@
/* =============================================================================
* B07_DesignDetail_UI_StandardSpec.ts
* ** ** .
*
* (PLAN 4-5b) `phase: "detail"` ( ··· )
* (2026-09-09 실측: B06 b05 phase ). B06
* · m(), .
*
* ** **( ). ,
* . ** ** ,
* .
* ** .** 1:0.7·1:0.8 .
* ========================================================================== */
const STONE_KINDS = ["야면석·호박돌", "깬잡석", "깬돌", "견치돌"] as const;
const SUPPLIES = ["채집", "구입"] as const;
const BACK_LENGTHS = ["25", "30", "35", "45", "55", "60", "75"] as const;
/** 정본 xls 탭 제목 그대로 — 터파기 기초 몫 0.45 대 0.07 을 가르는 축. */
const FOUNDATIONS = ["기초유", "기초버림"] as const;
/** 확정 ⑨ — 품셈 열이 기본, 실무 관행(깬돌 열)으로 바꿀 수 있게. */
const COEFF_BASES = ["품셈", "실무 관행"] as const;
/** 확정 2차 ⑩ — 기본 210. 180 은 국가기준 하한(돌쌓기 전용). */
const FILL_MPA = ["180", "210"] as const;
/** 표준도 장 하나 — 서버가 낸 것 중 이 폼이 쓰는 것만. */
export interface StandardSheetSpec {
key: string;
title: string;
type_id: string;
member_count: number;
options: Record<string, unknown>;
}
export interface StandardSpecResult {
sheet_key: string;
stone_kind: string | null;
stone_supply: string | null;
back_len_cm: string | null;
face_slope_ratio: string | null;
foundation: string | null;
stone_coeff_basis: string | null;
fill_concrete_mpa: string | null;
}
/** 이 종류가 돌쌓기 계열인가 — 옹벽·집수정에는 이 칸들이 뜻이 없다. */
const STONE_TYPES = new Set(["masonry_wet", "masonry_dry", "boulder_masonry"]);
function field(label: string, control: HTMLElement, hint?: string): HTMLLabelElement {
const wrap = document.createElement("label");
wrap.className = "b07-spec__field";
const name = document.createElement("span");
name.className = "b07-spec__label";
name.textContent = label;
wrap.append(name, control);
if (hint) {
const help = document.createElement("small");
help.className = "b07-spec__hint";
help.textContent = hint;
wrap.append(help);
}
return wrap;
}
function select(
choices: readonly string[],
current: unknown,
autoLabel: string,
): HTMLSelectElement {
const el = document.createElement("select");
el.className = "b07-spec__input";
// 첫 보기가 **빈 값** — 「정한 적 없음」이 고를 수 있는 상태여야 한다.
const blank = document.createElement("option");
blank.value = "";
blank.textContent = autoLabel;
el.append(blank);
for (const choice of choices) {
const option = document.createElement("option");
option.value = choice;
option.textContent = choice;
el.append(option);
}
el.value = current == null ? "" : String(current);
return el;
}
/**
* . `onSave` ** ** .
*/
export function buildStandardSpecPanel(
sheet: StandardSheetSpec,
judgedSlope: string | null,
onSave: (result: StandardSpecResult) => Promise<string[]>,
initialNotes: string[] = [],
): HTMLDivElement {
const panel = document.createElement("div");
panel.className = "b07-spec ui-sidebar-section";
const title = document.createElement("h3");
title.className = "b07-spec__title";
title.textContent = `제원 — ${sheet.title}`;
const scope = document.createElement("p");
scope.className = "b07-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.textContent = "이 종류는 표준도에서 받는 제원 칸이 아직 없습니다.";
panel.append(none);
return panel;
}
const options = sheet.options ?? {};
const kind = select(STONE_KINDS, options.stone_kind, "— 안 정함 —");
const supply = select(SUPPLIES, options.stone_supply, "— 안 정함(기본 채집) —");
const back = select(BACK_LENGTHS, options.back_len_cm, "— 안 정함 —");
const foundation = select(FOUNDATIONS, options.foundation, "— 안 정함 —");
const coeff = select(COEFF_BASES, options.stone_coeff_basis, "— 안 정함(품셈) —");
const mpa = select(FILL_MPA, options.fill_concrete_mpa, "— 안 정함(210) —");
const slope = document.createElement("input");
slope.className = "b07-spec__input";
slope.type = "text";
slope.inputMode = "decimal";
slope.placeholder = "비우면 자동";
slope.value = options.face_slope_ratio == null ? "" : String(options.face_slope_ratio);
panel.append(
field("돌 종류", kind),
field("조달", supply, "비우면 「캔다」로 봅니다."),
field("뒷길이 (㎝)", back, "품셈 일곱 규격 밖이면 물량이 서지 않습니다."),
field("기초", foundation, "터파기 몫이 갈립니다 — 기초유 0.45 · 기초버림 0.07 ㎥/m."),
field(
"전면 기울기 1:n",
slope,
judgedSlope ? `비우면 자동 — 지금 판정값 1:${judgedSlope}` : "비우면 자동으로 판정합니다.",
),
field("야면석 계수", coeff, "비우면 품셈 열을 씁니다."),
field("채움 강도 (MPa)", mpa, "비우면 210. 180 은 국가기준 하한입니다."),
);
const notes = document.createElement("ul");
notes.className = "b07-spec__notes";
// ⚠ 저장하면 표·그림을 다시 받으면서 **이 폼이 통째로 새로 그려진다** — 그때 안내가
// 지워지지 않게 밖에서 들고 있다가 다시 넣는다(2026-09-09 실화면에서 잡음).
const showNotes = (messages: string[]): void => {
notes.replaceChildren(
...messages.map((text) => {
const item = document.createElement("li");
item.textContent = text;
return item;
}),
);
notes.hidden = messages.length === 0;
};
showNotes(initialNotes);
const save = document.createElement("button");
save.type = "button";
save.className = "b07-spec__save";
save.textContent = "제원 저장";
save.addEventListener("click", () => {
void (async () => {
save.disabled = true;
save.textContent = "저장 중…";
try {
showNotes(
await onSave({
sheet_key: sheet.key,
stone_kind: kind.value || null,
stone_supply: supply.value || null,
back_len_cm: back.value || null,
face_slope_ratio: slope.value.trim() || null,
foundation: foundation.value || null,
stone_coeff_basis: coeff.value || null,
fill_concrete_mpa: mpa.value || null,
}),
);
} catch (error) {
// 실패도 같은 자리에 적는다 — 조용히 끝나면 사용자는 저장된 줄 안다.
showNotes([error instanceof Error ? error.message : "제원을 저장하지 못했습니다."]);
} finally {
save.disabled = false;
save.textContent = "제원 저장";
}
})();
});
panel.append(save, notes);
return panel;
}
+88 -2
View File
@@ -49,10 +49,16 @@
padding: var(--spacing-8);
}
/* 횡단도는 2열 배치 */
/* 횡단도는 2열 배치 , **칸이 좁아지면 1열로 접는다**(2026-09-08).
좌측 패널이 320 고정이던 동안은 2열이 128.8px 문제가 없었으나, 공용
오버레이가 좁은 폭에서 패널을 280·227·182 물리면서 칸이 108.8 82.4 60px
까지 줄었다. 실측: 560 에서 이름 31개 **16개**, 500 에서 **19개**
말줄임으로 잘려 9장 (No.26+14.0) 9장 됐다. 넘침 수치는 0 이라
잡히는 자리다 잘린 것은 `overflow: hidden` 안이라서.
가장 이름이 107px, 단추 안여백·테두리가 20px 이므로 120px 접는 문턱으로 둔다. */
.b07-drawing-group[data-kind="cross"] {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: var(--spacing-4);
}
@@ -321,3 +327,83 @@
padding: 4px 8px;
font-size: 0.78rem;
}
/* 확정을 건너뛴 개발 상태 알림 — 눈에 띄되 도면 목록을 밀어내지 않게 한 줄만. */
.b07-drawing-list__bypass {
margin: var(--spacing-4) 0 0;
padding: var(--spacing-4) var(--spacing-8);
border-left: 3px solid var(--color-warning, #d9a441);
color: var(--color-text-muted);
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);
}
@@ -301,6 +301,22 @@ SOURCE_BASIS_NOTE_RE = re.compile(r"([\d,]*\.?\d*)\s*개소\s*사용\s*당")
SOURCE_NOTE_LOOKAHEAD = 8
#: ⚠⚠ **「인」은 품의 단위이지 공종 밑수가 아니다**(2026-09-09 원문 대조로 넷이 오독으로 드러남).
#: 8-6-2 드론방제·8-6-3 지상방제 「(단위 : 인)」 — 그 「인」은 **소요인력**이고 공종 밑수는
#: ha 다. 13-2-4 야면석 채집 「(단위: 인 당)」 — 표 안이 **㎡당·㎥당** 두 줄이다.
#: ⇒ **원문에 「<숫자>인당」이 그대로 있을 때만** 밑수로 인정한다.
#: ⚠ **넓게 「인」을 통째로 버리면 2-2-5 천공기가 사라진다** — 그 표는 셀 안에 「천공인부
#: **1인당** 1대」로 숫자가 붙어 있다. 그래서 「숫자가 붙었나」로 좁게 가른다.
PERSON_UNITS = {"", ""}
def person_basis_ok(raw_quantity: str | None, unit: str | None) -> bool:
"""「인」 계열 밑수를 인정할 것인가 — **숫자가 붙어 있을 때만** 참."""
if unit not in PERSON_UNITS:
return True
return bool((raw_quantity or "").strip())
def basis_from_source(lines: list[str], line_no: int) -> tuple[float | None, str | None]:
"""표 바로 위 본문에서 밑수를 읽는다. 못 찾으면 `(None, None)` — 1 로 단정하지 않는다.
@@ -315,6 +331,8 @@ def basis_from_source(lines: list[str], line_no: int) -> tuple[float | None, str
break # 앞 표에 닿았다 — 그 위는 남의 밑수다
if m := SOURCE_BASIS_RATIO_RE.search(text):
raw = (m.group(1) or "").replace(",", "")
if not person_basis_ok(raw, m.group(2)):
continue # 「인」에 숫자가 안 붙었다 — 품의 단위이지 밑수가 아니다
try:
quantity = float(raw) if raw else 1.0
except ValueError:
@@ -324,6 +342,8 @@ def basis_from_source(lines: list[str], line_no: int) -> tuple[float | None, str
unit = m.group("u1") or m.group("u2")
raw = (m.group(1) if m.group("u1") else m.group(3)) or ""
raw = raw.replace(",", "")
if not person_basis_ok(raw, unit):
continue # 위와 같음 — 「(단위 : 인)」·「(단위: 인 당)」은 밑수가 아니다
try:
quantity = float(raw) if raw else 1.0
except ValueError:
@@ -347,14 +367,29 @@ def basis_from_source(lines: list[str], line_no: int) -> tuple[float | None, str
def detect_basis(table: dict[str, Any]) -> tuple[float | None, str | None]:
"""「100㎥당」 같은 밑수. 없으면 `(None, None)` — 단위당 1 로 단정하지 않는다."""
hay = " ".join(norm(h) for h in table.get("headers", []))
hay += " " + " ".join(norm(c) for r in table.get("rows", [])[:2] for c in r)
if m := BASIS_RE.search(hay):
try:
return float(m.group(1).replace(",", "")), m.group(2)
except ValueError:
return None, m.group(2)
"""「100㎥당」 같은 밑수. 없으면 `(None, None)` — 단위당 1 로 단정하지 않는다.
** 하나 안에서만 본다**(2026-09-09). 여러 칸을 이어 붙여 보면 ** 칸의 **
** 칸의 단위** 붙어 없는 밑수가 생긴다 13-2-4 야면석 채집이 그랬다:
| | 0.11 | 0.17 | 0.22 | 0.28 | **0.36**
**** | 0.60 |
이어 붙이면 0.36 되어 **밑수 0.36** 라는 값이 선다(원문에 없는 ).
표는 **· **이라 애초에 하나로 정한다 미확보 정직하다.
** 여기서도 숫자가 붙어야 인정한다** `BASIS_RE` 숫자를 요구하므로
2-2-5 천공인부 **1인당** 1 그대로 서고 (단위 : ) 선다.
"""
cells = [norm(h) for h in table.get("headers", []) or []]
cells += [norm(c) for r in (table.get("rows", []) or [])[:2] for c in r]
for cell in cells:
if not cell:
continue
if m := BASIS_RE.search(cell):
if not person_basis_ok(m.group(1), m.group(2)):
continue
try:
return float(m.group(1).replace(",", "")), m.group(2)
except ValueError:
return None, m.group(2)
return None, None
@@ -170,24 +170,49 @@ def build_rows(source: SummaryInput) -> list[SummaryRow]:
)
)
removal = slope.get("tree_removal_fill", 0.0) + slope.get("tree_removal_cut", 0.0)
rows.append(
SummaryRow(
group="지장목제거",
unit="",
amount=removal * _ratio(source, "obstacle_removal"),
amount_gross=removal,
application_ratio_pct=_ratio(source, "obstacle_removal") * 100.0,
application_ratio_breakdown={
"fill": _ratio(source, "obstacle_removal") * 100.0,
"cut": _ratio(source, "obstacle_removal") * 100.0,
},
quantity_breakdown={
"fill": slope.get("tree_removal_fill", 0.0) * _ratio(source, "obstacle_removal"),
"cut": slope.get("tree_removal_cut", 0.0) * _ratio(source, "obstacle_removal"),
},
note=_ratio_note(source, "obstacle_removal", "성토면+절토면"),
# ⭐ 2026-09-09 **사용자 확정 5차 2번** — 지장목제거를 **두 줄로 가른다**(실무 서식).
# 영월 설계내역서 1.9 지장목제거가 두 줄이고 **같은 면적을 나눠 쓴다**:
# 1.9.1 뿌리뽑기(장비+인력) 11,035㎡ @475
# 1.9.2 잡관목제거 벌목(5m미만) 11,035㎡ @882 ← 같은 11,035㎡
# ⚠⚠ **이중계상이 아니다** — 한 면적에 **다른 두 작업**이 얹히는 것이라 실무가 그렇게 적는다.
# (같은 작업을 두 축에서 두 번 세는 것과는 다른 자리다.)
# ⚠ 잡관목제거는 **품셈에 그 이름이 없다** — 실무는 별도 단가(영월 D00033)를 씀.
# 공종 없는 줄 보류(확정 5차 3번)에 걸리므로 **코드 없이 서고 사유가 붙는다.**
for item, why in (
(
"뿌리뽑기",
"확정 5차 2번 — 실무가 뿌리뽑기·잡관목제거 두 줄로 가름(같은 면적을 나눠 씀 · 이중계상 아님)",
),
(
"잡관목제거",
"확정 5차 2번 — 같은 면적에 얹히는 다른 작업(이중계상 아님)."
" ⚠ 품셈에 그 이름이 없어 실무는 별도 단가를 씀(영월 D00033) — 공종 보류 대상",
),
):
rows.append(
SummaryRow(
group="지장목제거",
item=item,
unit="",
amount=removal * _ratio(source, "obstacle_removal"),
amount_gross=removal,
application_ratio_pct=_ratio(source, "obstacle_removal") * 100.0,
application_ratio_breakdown={
"fill": _ratio(source, "obstacle_removal") * 100.0,
"cut": _ratio(source, "obstacle_removal") * 100.0,
},
quantity_breakdown={
"fill": slope.get("tree_removal_fill", 0.0)
* _ratio(source, "obstacle_removal"),
"cut": slope.get("tree_removal_cut", 0.0) * _ratio(source, "obstacle_removal"),
},
note=" · ".join(
part
for part in (_ratio_note(source, "obstacle_removal", "성토면+절토면"), why)
if part
),
)
)
)
rows.append(
SummaryRow(
group="층따기", spec="백호우", unit="", amount=slope.get("bench_cut_fill", 0.0)
@@ -225,13 +250,23 @@ def _haul_rows(source: SummaryInput) -> list[SummaryRow]:
ground = str(item.get("ground") or "")
distance = item.get("average_distance_m")
note = f"평균운반거리 {float(distance):.2f} m" if isinstance(distance, (int, float)) else ""
# ⚠ **자연상태로 싣는다** — 「운반거리 산정은 다짐상태, 내역서 수량은 자연상태」
# (config 5-4-3 인용). 유토곡선은 다짐으로 쌓으므로 여기서 ÷C 된 값을 받는다.
# 환산은 `HaulSummary` 한 곳에서만 하고, 여기서는 **고르기만** 한다(두 번 환산 금지).
compacted = float(item.get("volume_m3") or 0.0)
natural = item.get("natural_m3")
amount = float(natural) if isinstance(natural, (int, float)) else compacted
if isinstance(natural, (int, float)):
note = (note + f" · 자연상태 환산(다짐 {compacted:,.2f}㎥ ÷ C)").strip(" ·")
else:
note = (note + " · ⚠ 지반 갈래를 몰라 다짐상태 그대로임").strip(" ·")
if key == "free_haul":
note = (note + " · 내역 제외(품에 포함)").strip(" ·")
rows.append(
SummaryRow(
group=label,
item=ground,
amount=float(item.get("volume_m3") or 0.0),
amount=amount,
note=note,
in_bill=key != "free_haul",
)
+30 -1
View File
@@ -43,6 +43,11 @@ from __future__ import annotations
from typing import Any, Iterable
from B08_Quantity.B08_Quantity_Engine_BasisUnit import verify_unit_matches_basis
from B08_Quantity.B08_Quantity_Engine_Handoff_Spoil import spoil_haul_rows
from B08_Quantity.B08_Quantity_Engine_Handoff_Trench import (
rubble_base_rows,
structure_earthwork_rows,
)
# ⚠ 파일만 갈랐고 **계약은 그대로다** — 종전에 이 이름으로 가져다 쓰던 곳이 그대로 돌게
# 여기서 다시 내보낸다(2026-09-08 분리).
@@ -104,6 +109,8 @@ def build_handoff(
ground_classes: list[str] | None = None,
ground_methods: dict[str, str | None] | None = None,
concrete_placing_method: str | None = None,
bench_cut_depth_m: float | None = None,
structure_trench_water: str | None = None,
) -> dict[str, Any]:
"""B09 가 그대로 받는 모양. 없는 표는 건너뛰되 **빈 표와 구별해 적는다**."""
table = mapping or load_mapping()
@@ -112,17 +119,30 @@ def build_handoff(
methods = {key: value for key, value in (ground_methods or {}).items() if value}
if summary_table:
rows, misses = _earthwork_rows(summary_table, table, methods)
rows, misses = _earthwork_rows(summary_table, table, methods, bench_cut_depth_m)
work_items.extend(rows)
unmatched.extend(misses)
if haul_table:
rows, misses = _haul_rows(haul_table, table)
work_items.extend(rows)
unmatched.extend(misses)
# 사토를 실어 내는 줄 — 유토곡선이 사토를 내는데 **운반 줄이 없었다**(2026-09-08).
# 띠·이동에서만 운반이 만들어져 사토 잔량이 어디에도 안 실렸다.
work_items.extend(spoil_haul_rows(haul_table, table))
if unit_quantity_table:
rows, misses = _structure_rows(unit_quantity_table, table)
work_items.extend(rows)
unmatched.extend(misses)
# 구조물이 낸 터파기·되메우기·잔토 — **공종 축으로 올린다.**
# ⚠ 종전에는 성분으로만 있고 아무도 안 받아 **내역서에 한 줄도 안 나갔다**
# (2026-09-08 B09 매김에서 드러남). 실무 토적집계에는 서는 줄이다(울진 D12~D14).
rows, misses = structure_earthwork_rows(unit_quantity_table, table, structure_trench_water)
work_items.extend(rows)
unmatched.extend(misses)
# 기초잡석 — 버림이 선 구조물에 함께 서는 공종(확정 3차 ②). 묶음 구조물은 제외한다.
rows, misses = rubble_base_rows(unit_quantity_table, table)
work_items.extend(rows)
unmatched.extend(misses)
# 준비공·사방공 — **못 내는 줄도 사유와 함께** 보낸다(빼면 빠진 줄이 안 보인다).
# B군 종단배수 — 겹침을 합친 연장으로 종류별 한 줄(위 `_length_rows` 주석).
@@ -144,6 +164,10 @@ def build_handoff(
# 갈래 세트 — 「연암」이 몇 갈래 중 하나인지 알아야 ④가 선다.
"ground_class_set": ground_class_set,
"ground_classes": list(ground_classes or []),
# 갈래 이름 별칭 — 우리 「리핑암」을 일위대가가 「파쇄암」으로 부른다. 이름만 못 이어
# 도자 운반 금액이 안 붙던 자리라(2026-09-08 B09 매김) **인계본에 함께 싣는다.**
# ⚠ 갈래 이름 자체는 안 바꾼다 — 흙깎기(FP-09-04)가 「리핑암」으로 서 있다.
"ground_class_aliases": (table.ground_aliases or {}).get("aliases") or {},
"ground_methods": dict(methods),
# 시공법을 안 정해 공종을 못 고른 갈래 — 화면이 이 목록으로 안내를 띄운다.
"missing_method_classes": sorted(
@@ -183,6 +207,11 @@ def build_handoff(
result["basis_unit_warnings"] = verify_unit_matches_basis(
work_items, extra=table.declared_units()
)
# ⚠ 채집석 공제 — **양수 ㎥ 로 넘기기만** 한다. 빼는 자리는 유토곡선의 사토뿐이다
# (2026-09-09 세 창 확정 · 부호를 넘기면 두 번 뒤집힌다).
result["collected_stone_deduction_m3"] = float(
(unit_quantity_table or {}).get("collected_stone_deduction_m3") or 0.0
)
result["placing_notes"] = placing_notes
return result
@@ -134,11 +134,30 @@ BLOCKED_UNIT_DATA_MISSING = "unit_data_missing" # 원단위·표준 물량 자
BLOCKED_FORMULA_MISSING = "formula_missing" # 수량 산출식 자체가 없음
#: 사면 계열 — 토공집계에 함께 실리지만 출처가 사면적이다.
#: ⚠ `item` 칸이 **지반 갈래**인 공종 — 그 밖의 공종에서 `item` 은 **작업 갈래**다
#: (지장목제거의 「뿌리뽑기·잡관목제거」). 갈래로 읽으면 「시공법 미지정」이라는 **틀린 사유**가
#: 붙는다(2026-09-09 실측). 정의처는 `EarthworkSummary` 이고 여기서 그대로 가져다 쓴다.
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import ( # noqa: E402
GROUND_SPLIT_GROUPS as GROUND_SPLIT_GROUPS,
)
SLOPE_GROUPS = frozenset({"성토면다짐", "초류종자살포", "지장목제거", "층따기", "면고르기"})
#: 집계 합계 줄 — 내역 줄이 아니라 검산용이다.
SUBTOTAL_GROUPS = frozenset({"보정량계"})
#: ⚠⚠ **토공집계표에도 운반 줄이 있다 — 그런데 내역 줄은 운반표 쪽이다**(2026-09-09 실측).
#: 집계표는 실무 토적집계 모양이라 「무대·도자운반·덤프운반」을 함께 싣는데, 인계본에는
#: 운반표(`_haul_rows`, FP-10-11·FP-10-12)가 **같은 물량으로 또 실렸다** — 같은 운반이
#: 두 줄이었다(실측: 도자 17.389·61.130, 덤프 37.511·122.417 이 두 축에 각각).
#: ⇒ 집계 쪽은 **값은 내되 내역 줄이 아니다**. 빼지 않는 까닭은 검산(무대+도자+덤프 = 총
#: 운반토량)이 그 값을 쓰기 때문이다 — 무대를 그렇게 둔 것과 같은 규칙이다.
HAUL_SUMMARY_GROUPS = frozenset({"무대(종방향유용토)", "도자운반", "덤프운반"})
NOTE_HAUL_IN_SUMMARY = (
"집계 값 — 내역 줄은 운반표 쪽(FP-10-11·FP-10-12)이 세움. 여기서 또 세우면 같은 운반이"
" 두 줄이 됨(검산용으로만 실림)"
)
def _latest_dataset_path(directory: Path | None = None) -> Path | None:
folder = directory or DATASET_DIR
@@ -182,6 +201,9 @@ class WorkItemMapping:
unit_conversion: dict[str, Any] = field(default_factory=dict)
#: 배수관 — 관종별 공종·연장 키. 관 정본은 `pipe_points.json` 이다.
pipe: dict[str, Any] = field(default_factory=dict)
#: 갈래 이름 별칭 — 우리 「리핑암」 ↔ 일위대가 「파쇄암」처럼 **같은 것을 다른 이름**으로
#: 부르는 자리. ⚠ 갈래 이름 자체를 갈지 않는다(흙깎기 매핑이 그 이름으로 서 있다).
ground_aliases: dict[str, Any] = field(default_factory=dict)
def declared_units(self) -> dict[str, str]:
"""공종코드 → **매핑이 원문에서 읽어 적은 밑수 단위**. 적힌 줄만 낸다.
@@ -249,6 +271,7 @@ def load_mapping(path: Path | None = None) -> WorkItemMapping:
concrete_placing=payload.get("concrete_placing") or {},
pipe=payload.get("pipe") or {},
unit_conversion=(payload.get("composite") or {}).get("unit_conversion") or {},
ground_aliases=payload.get("ground_aliases") or {},
)
+149 -253
View File
@@ -14,12 +14,13 @@ from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import (
BLOCKED_FORMULA_MISSING,
BLOCKED_INPUT_MISSING,
BLOCKED_UNIT_DATA_MISSING,
GROUND_SPLIT_GROUPS,
HAUL_SUMMARY_GROUPS,
METHOD_TO_GROUND,
NOTE_HAUL_IN_SUMMARY,
NOTE_METHOD_MISSING,
ORIGIN_EARTHWORK,
ORIGIN_HAUL,
ORIGIN_PIPE,
ORIGIN_PREPARATION,
ORIGIN_SLOPE,
ORIGIN_STRUCTURE,
SLOPE_GROUPS,
@@ -30,14 +31,6 @@ from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import (
placing_code,
structure_kind,
)
from B08_Quantity.B08_Quantity_Engine_Preparation import (
STATUS_COUNTED_ELSEWHERE as PREP_COUNTED_ELSEWHERE,
)
from B08_Quantity.B08_Quantity_Engine_Preparation import (
STATUS_NOT_APPLICABLE as PREP_NOT_APPLICABLE,
)
from B08_Quantity.B08_Quantity_Engine_Preparation import STATUS_PENDING as PREP_PENDING
from B08_Quantity.B08_Quantity_Engine_Preparation import STATUS_READY as PREP_READY
from B08_Quantity.B08_Quantity_Wording import type_label as wording_type_label
@@ -89,10 +82,17 @@ def _basis_mismatch(entry: dict[str, Any] | None, unit: str) -> tuple[str, str,
)
#: 면적(㎡)으로 서지만 **길이를 곱해 ㎥ 로 내보내는** 공종 — 지금은 층따기뿐이다.
#: ⭐ 2026-09-09 사용자 확정 2차 ① — 「면적이 정본이고 부피는 사용자가 지정한 길이를 곱해 쓴다」.
#: ⚠ 면적을 없애지 않는다(횡단도 하단 표가 면적을 쓴다) — **㎥ 를 덧붙이는 것**이다.
AREA_TIMES_DEPTH_GROUPS = {"층따기"}
def _earthwork_rows(
summary_table: dict[str, Any],
mapping: WorkItemMapping,
methods: dict[str, str | None],
bench_cut_depth_m: float | None = None,
) -> tuple[list[dict[str, Any]], list[str]]:
"""토공집계표 줄을 내역 줄로 옮긴다.
@@ -105,9 +105,15 @@ def _earthwork_rows(
group = str(row.get("group") or "")
if not group:
continue
ground = row.get("item") or None
# ⚠ `item` 이 **지반 갈래인 공종**은 정해져 있다(흙깎기·측구터파기·구조물터파기).
# 그 밖(지장목제거의 「뿌리뽑기·잡관목제거」 같은 **작업 갈래**)을 갈래로 읽으면
# 「시공법 미지정으로 공종을 못 고름」이라는 **틀린 사유**가 붙는다(2026-09-09 실측).
is_ground_split = group in GROUND_SPLIT_GROUPS
ground = (row.get("item") or None) if is_ground_split else None
work_kind = None if is_ground_split else (row.get("item") or None)
origin = ORIGIN_SLOPE if group in SLOPE_GROUPS else ORIGIN_EARTHWORK
is_subtotal = group in SUBTOTAL_GROUPS
# ⚠ 운반은 **집계에도 오르고 운반표에도 오른다** — 내역 줄은 운반표 쪽 하나뿐이다.
is_subtotal = group in SUBTOTAL_GROUPS or group in HAUL_SUMMARY_GROUPS
lookup_ground, method_note = _mapping_ground(ground, methods)
entry = mapping.for_earthwork(group, lookup_ground) if method_note == "" else None
code = (entry or {}).get("work_item_code")
@@ -117,17 +123,36 @@ def _earthwork_rows(
amount = float(row.get("amount") or 0.0)
mismatch = _basis_mismatch(entry, unit) if code else None
spec_detail = ""
if mismatch is not None:
# 층따기 — 길이가 들어오면 **면적 × 길이**로 ㎥ 를 내 품셈 단가를 그대로 쓴다.
depth = float(bench_cut_depth_m or 0.0)
if group in AREA_TIMES_DEPTH_GROUPS and unit == "" and depth > 0:
spec_detail = f"면적 {amount:,.2f}× 길이 {depth:g}m"
unit, amount, mismatch = "", amount * depth, None
elif mismatch is not None:
spec_detail = f"집계 {amount:,.2f} {unit} (품셈 밑수 {mismatch[0]})"
unit, amount = mismatch[0], 0.0
# ⚠⚠ **코드가 없으면 반드시 막힘 표시를 단다**(2026-09-09 랩탑 메인 제보).
# 종전에는 `unmatched_work_items` 목록과 `bill_flag_warnings` 에만 실려,
# **줄 단위로 보는 쪽**(B09·화면)이 「코드도 없고 막힘 표시도 없는 멀쩡한 줄」로
# 읽었다 — 금액이 조용히 빠졌다(측구터파기·흙깎기 「굴삭기+브레카」).
# ⇒ 오늘 세운 규칙 「막혔다고 말하기 전에 `blocked_kind` 를 볼 것」의 **뒤집힌 얼굴**:
# 보는 쪽을 고쳤으면 **다는 쪽도** 빠짐없이 달아야 한다.
blocked_kind = mismatch[1] if mismatch else None
blocked_reason = mismatch[2] if mismatch else ""
if code is None and not is_subtotal:
label = f"{group}({ground})" if ground else group
unmatched.append(f"{label}{method_note}" if method_note else label)
if blocked_kind is None:
# 시공법을 고르면 풀리는 자리와, 품셈을 아직 못 이은 자리를 **갈라 적는다** —
# 다음에 할 일이 다르다(하나는 사용자 입력, 하나는 매핑 작업).
blocked_kind = BLOCKED_INPUT_MISSING if method_note else BLOCKED_UNIT_DATA_MISSING
blocked_reason = method_note or f"{label} 의 품셈 공종을 아직 못 이었습니다"
rows.append(
{
"work_item_code": code,
"name": group,
"spec": str(row.get("spec") or ""),
# 작업 갈래가 있으면 **규격 칸**에 적는다 — 갈래 축(`ground_class`)이 아니다.
"spec": str(work_kind or row.get("spec") or ""),
"unit": unit,
"quantity": amount,
# 반영률 — 곱하기는 여기가 끝이다. 받는 쪽은 적기만 한다.
@@ -150,16 +175,16 @@ def _earthwork_rows(
"secondary_axes": None,
"spec_class": None,
"spec_class_basis": "",
# 토공 줄도 막힐 수 있다 — 품셈 밑수와 단위가 다르면 그 사유가 실린다.
"blocked_kind": mismatch[1] if mismatch else None,
"blocked_reason": mismatch[2] if mismatch else "",
# 토공 줄도 막힐 수 있다 — 밑수 어긋남과 **코드 없음** 둘 다 여기 실린다.
"blocked_kind": blocked_kind,
"blocked_reason": blocked_reason,
"composite_not_ready": None,
# 합계 줄과 무대 줄은 값은 내되 내역에 안 선다.
"in_bill": bool(row.get("in_bill", True)) and not is_subtotal and mismatch is None,
"excavation_method": methods.get(ground) if ground else None,
"in_bill_reason": "집계 합계 줄 — 검산용"
if is_subtotal
else str(row.get("note") or ""),
"in_bill_reason": NOTE_HAUL_IN_SUMMARY
if group in HAUL_SUMMARY_GROUPS
else ("집계 합계 줄 — 검산용" if is_subtotal else str(row.get("note") or "")),
"origin": origin,
}
)
@@ -179,13 +204,40 @@ def _haul_rows(
in_bill = bool(row.get("in_bill", True)) and entry.get("in_bill", True)
if code is None and in_bill:
unmatched.append(f"운반({equipment})")
# ⚠ 코드가 없으면 **줄에 막힘 표시를 단다**(2026-09-09) — 목록에만 실으면 줄 단위로
# 보는 쪽이 「멀쩡한 줄」로 읽어 금액이 조용히 빠진다(도자운반·덤프운반이 그랬다).
# ⚠ `in_bill` 이 False 인 무대 줄은 **막힌 것이 아니다** — 품에 포함이라 안 세우는 것.
haul_blocked = BLOCKED_UNIT_DATA_MISSING if (code is None and in_bill) else None
haul_blocked_reason = (
f"운반({equipment})의 품셈 공종을 아직 못 이었습니다" if haul_blocked else ""
)
# ⚠⚠ **내역서 수량은 자연상태다** — 유토곡선은 다짐상태로 쌓고(운반거리를 그 기준으로
# 재야 맞는다) 내역에 오르는 수량은 되돌린 값이다(`config_system_design` 5-4-3
# 「운반거리 산정 시 모든 수량은 다짐상태로 환산해 계산하고, **내역서에 적용하는
# 수량은 자연상태로 한다**」). 되돌린 값이 없으면 갈래를 못 붙인 것이라 **다짐 그대로
# 두고 사유를 낸다** — 토사 계수로 눅이면 근거 없이 금액이 움직인다.
compacted = float(row.get("volume_m3") or 0.0)
natural = row.get("natural_m3")
state_note = ""
if isinstance(natural, (int, float)) and float(natural) > 0:
quantity, factor = float(natural), row.get("conversion_c")
state_note = (
f"다짐 {compacted:,.2f}㎥ ÷ C {factor} = 자연상태"
if factor
else f"다짐 {compacted:,.2f}㎥ 을 되돌린 자연상태"
)
else:
quantity = compacted
state_note = (
"⚠ 다짐상태 그대로 — 갈래를 못 붙여 되돌릴 계수가 없음(내역 수량은 자연상태여야 함)"
)
rows.append(
{
"work_item_code": code,
"name": f"{equipment} 운반",
"spec": str(row.get("ground") or ""),
"unit": "",
"quantity": float(row.get("volume_m3") or 0.0),
"quantity": quantity,
# 운반에는 반영률 개념이 없다 — 그래서 `None` 이다(0 이 아니다).
"quantity_gross": None,
"application_ratio_pct": None,
@@ -197,17 +249,21 @@ def _haul_rows(
"excavation_method": None,
"station_from": None,
"station_to": None,
"spec_detail": "",
"spec_detail": state_note,
"composite_parts": None,
"structure_kind": None,
# 토공·운반 줄에는 갈래 축이 없다 — 그래도 **칸은 둔다**(계약이 한 모양).
"variant_axis": None,
"variant_value": None,
# ⚠⚠ **갈래를 통로로 보낸다**(2026-09-09). 운반 단가가 지반 갈래로 갈리는데
# `spec` 문자열만 보내고 있어 받는 쪽이 갈래를 못 골랐다 — 「버림」에서
# 275,584원이 사라졌던 그 자리와 같다. 갈래는 `variant_value` 한 통로로만.
# ⚠ 이름은 **우리 갈래 그대로**(리핑암) 보낸다 — 일위대가의 「파쇄암」과는
# 인계본의 `ground_class_aliases` 가 이어 준다(이름을 갈면 흙깎기가 어긋난다).
"variant_axis": "ground_class" if row.get("ground") else None,
"variant_value": str(row.get("ground")) if row.get("ground") else None,
"secondary_axes": None,
"spec_class": None,
"spec_class_basis": "",
"blocked_kind": None,
"blocked_reason": "",
"blocked_kind": haul_blocked,
"blocked_reason": haul_blocked_reason,
"composite_not_ready": None,
"in_bill": in_bill,
"in_bill_reason": str(entry.get("reason") or ""),
@@ -352,6 +408,19 @@ def _structure_rows(
bill_quantity = float(structure.get("billing_quantity") or 0.0)
else:
bill_unit, bill_quantity = "m", length
# ⚠⚠ **물량 0 을 내역에 세우지 않는다**(2026-09-09 감사). 물넘이포장이 면적을 안 받아
# `0.0 ㎡` 로 서고 있었다 — 코드가 붙어 있어 **0 원 줄**이 만들어지고, 화면에는
# 「값이 있는 줄」로 보인다. 0 은 「없음」과 구별이 안 된다(오늘 표토에서 겪은 자리).
# ⇒ 줄은 그대로 넘기되 **내역에서 빼고 까닭을 적는다.**
in_bill = bill_quantity > 0
zero_reason = ""
if not in_bill:
notes = "; ".join(str(note) for note in (structure.get("notes") or []))
zero_reason = (
f"물량이 0 이라 내역에 안 세움 — {notes}"
if notes
else "물량이 0 이라 내역에 안 세움 — 저장 제원에서 치수·면적을 넣으면 값이 섭니다"
)
rows.append(
{
"work_item_code": code,
@@ -375,8 +444,8 @@ def _structure_rows(
# 철근이 있나 없나로 자동 판정 — 사람이 고르는 값이 아니다.
"structure_kind": kind,
# ⚠ 줄마다 **왜 막혔는지**를 싣는다 — 안 실으면 받는 쪽 화면이 빈다.
"blocked_kind": blocked_kind,
"blocked_reason": blocked_reason,
"blocked_kind": blocked_kind or (BLOCKED_INPUT_MISSING if not in_bill else None),
"blocked_reason": blocked_reason or zero_reason,
# 규격 갈래(뒷길이 …㎝ 이하) — 못 고르면 사유가 남는다.
# ⚠ **갈래 키 문자열을 우리가 조립하지 않는다** (2026-09-07 계약 변경).
# 품셈 원문이 물결표를 섞어 쓴다(`` U+223C / `` U+FF5E). 두 창이 각자
@@ -391,8 +460,8 @@ def _structure_rows(
"spec_class_basis": class_basis,
# ⚠ 물량을 못 채운 조각 — 0 으로 적지 않고 사유와 함께 드러낸다.
"composite_not_ready": parts_missing or None,
"in_bill": True,
"in_bill_reason": (composite or {}).get("why", ""),
"in_bill": in_bill,
"in_bill_reason": zero_reason or (composite or {}).get("why", ""),
"origin": ORIGIN_STRUCTURE,
}
)
@@ -406,6 +475,17 @@ def _structure_rows(
#: (넣으려면 돌쌓기 일위대가에 타설 품이 있는지부터 확인할 것 — B09 ㉢ 과 같은 자리.)
PLACING_TARGET_NAMES = frozenset({"콘크리트", "버림콘크리트", "레미콘"})
#: 버림 타설 줄에 **표시**할 이름 — 실무 내역 표기 그대로(「레미콘타설(장비) 무근,버림」).
#: ⚠⚠ **표시 문구일 뿐 갈래가 아니다.** 품셈 12-1-1 의 갈래는 무근/철근/소형 셋뿐이고
#: **「버림」이라는 열이 없다.** 실무도 줄 이름만 「무근,버림」이고 품은 무근 것을 쓴다
#: (봉화 제50호표 단가가 「무근」과 같음).
#: ⇒ `variant_value`·`structure_kind` 는 **「무근구조물」 그대로** 보내고 여기 이름은
#: `spec` 에만 쓴다. 갈래 축에 없는 값을 보내면 받는 쪽이 단가를 못 고른다
#: (2026-09-09 실측: 275,584원이 통째로 빠졌다).
BLINDING_PLACING_LABEL = "무근,버림"
#: 버림이 실제로 쓰는 품셈 갈래 — 무근이다.
BLINDING_PLACING_KIND = "무근구조물"
def _placing_rows(
unit_quantity_table: dict[str, Any],
@@ -435,20 +515,30 @@ def _placing_rows(
# ⚠ 묶음이 아직 미확보라 지금은 값이 안 걸렸을 뿐, 묶음이 서는 날 두 번이 된다.
if mapping.composite_for(str(structure.get("type_id") or "")):
continue
volume = sum(
float(component.get("amount") or 0.0)
for component in structure.get("components") or []
if str(component.get("name") or "").strip() in PLACING_TARGET_NAMES
and component.get("unit") == ""
)
if volume <= 0:
continue
buckets[structure_kind(structure)] = buckets.get(structure_kind(structure), 0.0) + volume
# ⚠ 버림은 **따로 센다** — 실무 내역이 「레미콘타설(장비) **무근,버림**」으로 갈라
# 적는다(봉화 제50호표, 2026-09-09 데스크탑 보조 확인). 같은 공종·같은 단가라
# 금액은 안 움직이고 **이름만 맞추는 것**이다.
for component in structure.get("components") or []:
name = str(component.get("name") or "").strip()
if name not in PLACING_TARGET_NAMES or component.get("unit") != "":
continue
volume = float(component.get("amount") or 0.0)
if volume <= 0:
continue
# (갈래, 표시 이름) 으로 담는다 — 갈래는 품셈 축, 표시는 실무 줄 이름.
if name == "버림콘크리트":
key = (BLINDING_PLACING_KIND, BLINDING_PLACING_LABEL)
else:
kind = structure_kind(structure)
key = (kind, kind)
buckets[key] = buckets.get(key, 0.0) + volume
rows = [
{
"work_item_code": code,
"name": "콘크리트 타설",
"spec": kind,
# ⚠ 표시 이름과 갈래를 **가른다** — 표시는 실무 줄 이름(「무근,버림」),
# 갈래(`variant_value`)는 품셈 축(무근/철근/소형)이라야 단가가 붙는다.
"spec": label,
"unit": "",
"quantity": volume,
"quantity_gross": None,
@@ -461,7 +551,7 @@ def _placing_rows(
"station_from": None,
"station_to": None,
"excavation_method": None,
"spec_detail": kind,
"spec_detail": label,
"composite_parts": None,
"structure_kind": kind,
"blocked_kind": None,
@@ -478,7 +568,7 @@ def _placing_rows(
"in_bill_reason": "",
"origin": ORIGIN_STRUCTURE,
}
for kind, volume in sorted(buckets.items())
for (kind, label), volume in sorted(buckets.items())
]
notes: list[str] = []
if rows and used_default:
@@ -489,214 +579,20 @@ def _placing_rows(
return rows, notes
def _preparation_rows(preparation_table: dict[str, Any]) -> list[dict[str, Any]]:
"""준비공·사방공 줄 — **값이 서는 줄도, 못 서는 줄도** 함께 보낸다.
# ⚠ 준비공·배수관·연장·자재 줄은 700줄 제한으로 `_Handoff_Rows_Prep` 에 갈라 뒀다
# (2026-09-09). 여기서 다시 내보내 부르는 쪽 import 는 그대로 둔다.
from B08_Quantity.B08_Quantity_Engine_Handoff_Rows_Prep import ( # noqa: E402
_length_rows,
_material_rows,
_pipe_rows,
_prep_blocked_kind,
_preparation_rows,
)
**줄을 빼면 빠졌다는 사실조차 보인다** (2026-09-08 보조 제보).
받는 화면에서 내역서에 원래 없는 우리가 아직 내는 구별되지 않는다.
그래서 내는 줄도 `in_bill: False` + `blocked_reason` 으로 실어 보낸다
**금액은 붙되 무엇이 채워지면 풀리는지 함께 간다.**
표가 통째로 가고 있었다 표토제거( 있음·`FP-09-15`)·규준틀(개소·`FP-11-02`)
화면에는 서는데 인계에는 없었다. 사유를 실어 달라 요청을 보다 드러났다.
"""
rows: list[dict[str, Any]] = []
for row in preparation_table.get("rows") or []:
status = str(row.get("status") or "")
amount = row.get("amount")
ready = status == PREP_READY and amount is not None
rows.append(
{
"work_item_code": row.get("work_item_code"),
"name": str(row.get("item") or ""),
"spec": str(row.get("group") or ""),
"unit": str(row.get("unit") or ""),
"quantity": float(amount or 0.0),
"quantity_gross": None,
"application_ratio_pct": None,
"application_ratio_breakdown": None,
"quantity_breakdown": None,
"ground_class": None,
"haul_distance_m": None,
"haul_equipment": None,
"station_from": None,
"station_to": None,
"excavation_method": None,
"spec_detail": str(row.get("group") or ""),
"composite_parts": None,
"structure_kind": None,
# ⚠ 못 서는 까닭을 그대로 넘긴다 — 받는 쪽이 「만들어야 할 것」 목록에 얹는다.
"blocked_kind": None if ready else _prep_blocked_kind(status),
"blocked_reason": "" if ready else str(row.get("reason") or status),
"variant_axis": None,
"variant_value": None,
"secondary_axes": None,
"spec_class": None,
"spec_class_basis": str(row.get("reason") or ""),
"composite_not_ready": None,
# 값이 없는 줄은 **내역에 세우지 않는다** — 0 원 줄을 만들면 더 나쁘다.
"in_bill": ready,
"in_bill_reason": "" if ready else str(row.get("reason") or status),
"origin": ORIGIN_PREPARATION,
}
)
return rows
def _prep_blocked_kind(status: str) -> str | None:
"""준비공 줄의 상태를 **막힌 갈래 셋** 중 하나로 옮긴다. 모르는 상태는 `None`."""
if status == PREP_PENDING:
return BLOCKED_INPUT_MISSING
if status == PREP_COUNTED_ELSEWHERE:
# 다른 표에서 이미 선 줄 — 막힌 것이 아니라 **여기서 세면 안 되는** 줄이다.
return None
if status == PREP_NOT_APPLICABLE:
return None
return BLOCKED_UNIT_DATA_MISSING
def _pipe_rows(pipe_table: dict[str, Any]) -> list[dict[str, Any]]:
"""배수관 줄 — 값이 서는 줄도, 못 서는 줄도 함께 보낸다(준비공과 같은 규칙).
터파기·되메우기를 붙이지 않는다 부설과 굴착이 각각 오면 **같은 굴착을 ** 센다
(B09 가드와 같은 자리).
"""
rows: list[dict[str, Any]] = []
for row in pipe_table.get("rows") or []:
ready = bool(row.get("in_bill"))
rows.append(
{
"work_item_code": row.get("work_item_code"),
"name": f"배수관({row.get('kind')})",
"spec": f"Ø{row.get('variant_value')}" if row.get("variant_value") else "",
"unit": str(row.get("unit") or "m"),
"quantity": float(row.get("quantity") or 0.0),
"quantity_gross": None,
"application_ratio_pct": None,
"application_ratio_breakdown": None,
"quantity_breakdown": None,
"ground_class": None,
"haul_distance_m": None,
"haul_equipment": None,
"station_from": row.get("chainage_m"),
"station_to": row.get("chainage_m"),
"excavation_method": None,
"spec_detail": f"Ø{row.get('variant_value')}" if row.get("variant_value") else "",
"composite_parts": None,
"structure_kind": None,
"blocked_kind": row.get("blocked_kind"),
"blocked_reason": str(row.get("blocked_reason") or ""),
# 갈래는 **저장 원본값**만 — 「…㎜ 이하」 구간 나누기는 원문을 읽는 쪽 몫.
"variant_axis": row.get("variant_axis"),
"variant_value": row.get("variant_value"),
"secondary_axes": None,
"spec_class": None,
"spec_class_basis": str(row.get("blocked_reason") or ""),
"composite_not_ready": None,
"in_bill": ready,
"in_bill_reason": "" if ready else str(row.get("blocked_reason") or ""),
"origin": ORIGIN_PIPE,
}
)
return rows
def _length_rows(
length_table: list[dict[str, Any]], mapping: WorkItemMapping
) -> list[dict[str, Any]]:
"""B군 종단배수 — **종류별 한 줄**로 낸다(연장이 곧 수량).
** 구조물별로 내나** `common_util_structure_lengths` **겹친 구간을 합쳐**
준다. 같은 시설을 겹쳐 놓으면 구조물별로 세는 순간 구간을 ** ** 센다.
규칙(겹침 합치기 · 측구 제외 · 소관 제외) 이미 함수에 있으므로
** 벌로 짜지 않는다**(2026-09-08 랩탑 제안, 합의).
**C군(돌쌓기·옹벽 ) 여기로 오지 않는다** 함수는 종류별로 뭉쳐 내는데,
C군은 **측점·규격이 줄마다 달라** 구조물별로 서야 하고 자재도 줄마다 나온다.
실무 내역도 B군은 산마루측구 40m , C군은 구조물별 줄이다.
겹침이 있으면(`length_m != raw_length_m`) **숨기지 않고 비고에 적는다.**
"""
rows: list[dict[str, Any]] = []
for entry in length_table or []:
type_id = str(entry.get("type_id") or "")
found = mapping.for_structure(type_id)
code = (found or {}).get("work_item_code")
length = float(entry.get("length_m") or 0.0)
raw = float(entry.get("raw_length_m") or length)
# 구간 목록 — **겹침을 지운 뒤**의 것이라 그 합이 곧 `length_m` 이다
# (80~120 과 100~140 은 80~140 한 줄로 합쳐져 온다, 2026-09-08 랩탑 창).
# ⚠ 표기(`NO.4+0.0`)는 만들지 않는다 — 측점 간격을 아는 화면 몫이다.
spans = [
span
for span in (entry.get("spans") or [])
if span.get("start_m") is not None and span.get("end_m") is not None
]
span_note = " · ".join(f"{s['start_m']:g}~{s['end_m']:g}m" for s in spans)
note = f"구간 {span_note}" if span_note else ""
if abs(raw - length) > 1e-9:
겹침 = f"입력 구간 합 {raw:g}m 에서 겹친 {raw - length:g}m 를 뺀 값"
note = f"{note} · {겹침}" if note else 겹침
# ⚠ 겹침 설명은 **비고**이지 막힌 사유가 아니다 — `blocked_reason` 에 넣으면
# 받는 쪽이 「막힌 줄」로 읽어 금액을 안 붙인다(2026-09-08 실측에서 그랬다).
reason = ""
if code is None:
reason = f"{entry.get('name') or type_id} — 품셈 공종을 아직 못 이었습니다"
elif length <= 0:
reason = f"{entry.get('name') or type_id} — 연장이 0 이라 값이 서지 않습니다"
rows.append(
{
"work_item_code": code,
"name": str(entry.get("name") or type_id),
"spec": f"{entry.get('count')}개소",
"unit": "m",
"quantity": length,
"quantity_gross": raw if note else None,
"application_ratio_pct": None,
"application_ratio_breakdown": None,
"quantity_breakdown": None,
"ground_class": None,
"haul_distance_m": None,
"haul_equipment": None,
# 여러 구간이면 **처음과 끝**만 싣는다 — 사이 구간은 비고에 다 적혀 있다.
"station_from": spans[0]["start_m"] if spans else None,
"station_to": spans[-1]["end_m"] if spans else None,
"excavation_method": None,
"spec_detail": f"{entry.get('count')}개소",
"composite_parts": None,
"structure_kind": None,
"blocked_kind": None if (code and length > 0) else BLOCKED_FORMULA_MISSING,
"blocked_reason": reason,
"variant_axis": None,
"variant_value": None,
"secondary_axes": None,
"spec_class": None,
"spec_class_basis": note,
"composite_not_ready": None,
"in_bill": bool(code and length > 0),
"in_bill_reason": "" if (code and length > 0) else reason,
"origin": ORIGIN_STRUCTURE,
}
)
return rows
def _material_rows(material_table: dict[str, Any]) -> list[dict[str, Any]]:
"""자재 줄 — **공종코드를 붙이지 않는다.** 자재 축은 B09 카탈로그가 잇는다(8-7)."""
rows: list[dict[str, Any]] = []
for row in material_table.get("rows") or []:
rows.append(
{
"material_name": row.get("name"),
"spec": row.get("spec") or "",
"unit": row.get("unit"),
"net_amount": row.get("net_amount"),
"total_amount": row.get("total_amount"),
"surcharge_pct": row.get("surcharge_pct"),
"surcharge_note": row.get("note") or "",
"supply_type": row.get("supply"),
"install_by": row.get("install_by"),
"source_structure": row.get("sources") or [],
}
)
return rows
__all__ = [
"_length_rows",
"_material_rows",
"_pipe_rows",
"_prep_blocked_kind",
"_preparation_rows",
]
@@ -0,0 +1,255 @@
"""인계 줄 — **준비공·배수관·연장·자재** 네 갈래 (`Engine_Handoff_Rows` 에서 갈라냄).
** 갈랐나** 700 제한(2026-09-09). **줄의 모양은 하나도 바뀐다.**
빌더가 여럿이라 칸을 하나 늘리면 **여기와 저기를 함께** 고쳐야 한다 계약 시험
(`tmp/tests/test_b08_handoff_contract.py`) 모든 줄이 같은 칸을 갖는가 그것을 지킨다.
"""
from __future__ import annotations
from typing import Any
from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import (
BLOCKED_FORMULA_MISSING,
BLOCKED_INPUT_MISSING,
BLOCKED_UNIT_DATA_MISSING,
ORIGIN_PIPE,
ORIGIN_PREPARATION,
ORIGIN_STRUCTURE,
WorkItemMapping,
)
from B08_Quantity.B08_Quantity_Engine_Preparation_Status import (
STATUS_COUNTED_ELSEWHERE as PREP_COUNTED_ELSEWHERE,
)
from B08_Quantity.B08_Quantity_Engine_Preparation_Status import (
STATUS_NOT_APPLICABLE as PREP_NOT_APPLICABLE,
)
from B08_Quantity.B08_Quantity_Engine_Preparation_Status import (
STATUS_PENDING as PREP_PENDING,
)
from B08_Quantity.B08_Quantity_Engine_Preparation_Status import (
STATUS_READY as PREP_READY,
)
def _preparation_rows(preparation_table: dict[str, Any]) -> list[dict[str, Any]]:
"""준비공·사방공 줄 — **값이 서는 줄도, 못 서는 줄도** 함께 보낸다.
**줄을 빼면 빠졌다는 사실조차 보인다** (2026-09-08 보조 제보).
받는 화면에서 내역서에 원래 없는 우리가 아직 내는 구별되지 않는다.
그래서 내는 줄도 `in_bill: False` + `blocked_reason` 으로 실어 보낸다
**금액은 붙되 무엇이 채워지면 풀리는지 함께 간다.**
표가 통째로 가고 있었다 표토제거( 있음·`FP-09-15`)·규준틀(개소·`FP-11-02`)
화면에는 서는데 인계에는 없었다. 사유를 실어 달라 요청을 보다 드러났다.
"""
rows: list[dict[str, Any]] = []
for row in preparation_table.get("rows") or []:
status = str(row.get("status") or "")
amount = row.get("amount")
ready = status == PREP_READY and amount is not None
rows.append(
{
"work_item_code": row.get("work_item_code"),
"name": str(row.get("item") or ""),
"spec": str(row.get("group") or ""),
"unit": str(row.get("unit") or ""),
"quantity": float(amount or 0.0),
"quantity_gross": None,
"application_ratio_pct": None,
"application_ratio_breakdown": None,
"quantity_breakdown": None,
"ground_class": None,
"haul_distance_m": None,
"haul_equipment": None,
"station_from": None,
"station_to": None,
"excavation_method": None,
# ⚠ **값이 서는 줄에도 근거를 싣는다**(2026-09-09) — 종전에는 대분류 이름만
# 실어, 값이 서는 순간 **왜 그 값인지가 사라졌다**(제근이 면적 축으로 확정돼
# 값이 서자 「교차 참조: 건설품셈 3-9-2」가 화면에서 사라진 자리).
# 못 서는 줄은 종전대로 `blocked_reason` 이 따로 든다.
"spec_detail": " · ".join(
part
for part in (str(row.get("group") or ""), str(row.get("reason") or ""))
if part
),
"composite_parts": None,
"structure_kind": None,
# ⚠ 못 서는 까닭을 그대로 넘긴다 — 받는 쪽이 「만들어야 할 것」 목록에 얹는다.
"blocked_kind": None if ready else _prep_blocked_kind(status),
"blocked_reason": "" if ready else str(row.get("reason") or status),
# ⚠ 준비공 줄도 갈래를 실어 보낸다(2026-09-09) — 종전에는 늘 `None` 이라
# 표토 운반처럼 **부모 공종코드**로 가는 줄이 B09 에서 「후보 N건」에 머물렀다.
"variant_axis": row.get("variant_axis"),
"variant_value": row.get("variant_value"),
"secondary_axes": None,
"spec_class": None,
"spec_class_basis": str(row.get("reason") or ""),
"composite_not_ready": None,
# 값이 없는 줄은 **내역에 세우지 않는다** — 0 원 줄을 만들면 더 나쁘다.
"in_bill": ready,
"in_bill_reason": "" if ready else str(row.get("reason") or status),
"origin": ORIGIN_PREPARATION,
}
)
return rows
def _prep_blocked_kind(status: str) -> str | None:
"""준비공 줄의 상태를 **막힌 갈래 셋** 중 하나로 옮긴다. 모르는 상태는 `None`."""
if status == PREP_PENDING:
return BLOCKED_INPUT_MISSING
if status == PREP_COUNTED_ELSEWHERE:
# 다른 표에서 이미 선 줄 — 막힌 것이 아니라 **여기서 세면 안 되는** 줄이다.
return None
if status == PREP_NOT_APPLICABLE:
return None
return BLOCKED_UNIT_DATA_MISSING
def _pipe_rows(pipe_table: dict[str, Any]) -> list[dict[str, Any]]:
"""배수관 줄 — 값이 서는 줄도, 못 서는 줄도 함께 보낸다(준비공과 같은 규칙).
터파기·되메우기를 붙이지 않는다 부설과 굴착이 각각 오면 **같은 굴착을 ** 센다
(B09 가드와 같은 자리).
"""
rows: list[dict[str, Any]] = []
for row in pipe_table.get("rows") or []:
ready = bool(row.get("in_bill"))
rows.append(
{
"work_item_code": row.get("work_item_code"),
"name": f"배수관({row.get('kind')})",
"spec": f"Ø{row.get('variant_value')}" if row.get("variant_value") else "",
"unit": str(row.get("unit") or "m"),
"quantity": float(row.get("quantity") or 0.0),
"quantity_gross": None,
"application_ratio_pct": None,
"application_ratio_breakdown": None,
"quantity_breakdown": None,
"ground_class": None,
"haul_distance_m": None,
"haul_equipment": None,
"station_from": row.get("chainage_m"),
"station_to": row.get("chainage_m"),
"excavation_method": None,
"spec_detail": f"Ø{row.get('variant_value')}" if row.get("variant_value") else "",
"composite_parts": None,
"structure_kind": None,
"blocked_kind": row.get("blocked_kind"),
"blocked_reason": str(row.get("blocked_reason") or ""),
# 갈래는 **저장 원본값**만 — 「…㎜ 이하」 구간 나누기는 원문을 읽는 쪽 몫.
"variant_axis": row.get("variant_axis"),
"variant_value": row.get("variant_value"),
"secondary_axes": None,
"spec_class": None,
"spec_class_basis": str(row.get("blocked_reason") or ""),
"composite_not_ready": None,
"in_bill": ready,
"in_bill_reason": "" if ready else str(row.get("blocked_reason") or ""),
"origin": ORIGIN_PIPE,
}
)
return rows
def _length_rows(
length_table: list[dict[str, Any]], mapping: WorkItemMapping
) -> list[dict[str, Any]]:
"""B군 종단배수 — **종류별 한 줄**로 낸다(연장이 곧 수량).
** 구조물별로 내나** `common_util_structure_lengths` **겹친 구간을 합쳐**
준다. 같은 시설을 겹쳐 놓으면 구조물별로 세는 순간 구간을 ** ** 센다.
규칙(겹침 합치기 · 측구 제외 · 소관 제외) 이미 함수에 있으므로
** 벌로 짜지 않는다**(2026-09-08 랩탑 제안, 합의).
**C군(돌쌓기·옹벽 ) 여기로 오지 않는다** 함수는 종류별로 뭉쳐 내는데,
C군은 **측점·규격이 줄마다 달라** 구조물별로 서야 하고 자재도 줄마다 나온다.
실무 내역도 B군은 산마루측구 40m , C군은 구조물별 줄이다.
겹침이 있으면(`length_m != raw_length_m`) **숨기지 않고 비고에 적는다.**
"""
rows: list[dict[str, Any]] = []
for entry in length_table or []:
type_id = str(entry.get("type_id") or "")
found = mapping.for_structure(type_id)
code = (found or {}).get("work_item_code")
length = float(entry.get("length_m") or 0.0)
raw = float(entry.get("raw_length_m") or length)
# 구간 목록 — **겹침을 지운 뒤**의 것이라 그 합이 곧 `length_m` 이다
# (80~120 과 100~140 은 80~140 한 줄로 합쳐져 온다, 2026-09-08 랩탑 창).
# ⚠ 표기(`NO.4+0.0`)는 만들지 않는다 — 측점 간격을 아는 화면 몫이다.
spans = [
span
for span in (entry.get("spans") or [])
if span.get("start_m") is not None and span.get("end_m") is not None
]
span_note = " · ".join(f"{s['start_m']:g}~{s['end_m']:g}m" for s in spans)
note = f"구간 {span_note}" if span_note else ""
if abs(raw - length) > 1e-9:
겹침 = f"입력 구간 합 {raw:g}m 에서 겹친 {raw - length:g}m 를 뺀 값"
note = f"{note} · {겹침}" if note else 겹침
# ⚠ 겹침 설명은 **비고**이지 막힌 사유가 아니다 — `blocked_reason` 에 넣으면
# 받는 쪽이 「막힌 줄」로 읽어 금액을 안 붙인다(2026-09-08 실측에서 그랬다).
reason = ""
if code is None:
reason = f"{entry.get('name') or type_id} — 품셈 공종을 아직 못 이었습니다"
elif length <= 0:
reason = f"{entry.get('name') or type_id} — 연장이 0 이라 값이 서지 않습니다"
rows.append(
{
"work_item_code": code,
"name": str(entry.get("name") or type_id),
"spec": f"{entry.get('count')}개소",
"unit": "m",
"quantity": length,
"quantity_gross": raw if note else None,
"application_ratio_pct": None,
"application_ratio_breakdown": None,
"quantity_breakdown": None,
"ground_class": None,
"haul_distance_m": None,
"haul_equipment": None,
# 여러 구간이면 **처음과 끝**만 싣는다 — 사이 구간은 비고에 다 적혀 있다.
"station_from": spans[0]["start_m"] if spans else None,
"station_to": spans[-1]["end_m"] if spans else None,
"excavation_method": None,
"spec_detail": f"{entry.get('count')}개소",
"composite_parts": None,
"structure_kind": None,
"blocked_kind": None if (code and length > 0) else BLOCKED_FORMULA_MISSING,
"blocked_reason": reason,
"variant_axis": None,
"variant_value": None,
"secondary_axes": None,
"spec_class": None,
"spec_class_basis": note,
"composite_not_ready": None,
"in_bill": bool(code and length > 0),
"in_bill_reason": "" if (code and length > 0) else reason,
"origin": ORIGIN_STRUCTURE,
}
)
return rows
def _material_rows(material_table: dict[str, Any]) -> list[dict[str, Any]]:
"""자재 줄 — **공종코드를 붙이지 않는다.** 자재 축은 B09 카탈로그가 잇는다(8-7)."""
rows: list[dict[str, Any]] = []
for row in material_table.get("rows") or []:
rows.append(
{
"material_name": row.get("name"),
"spec": row.get("spec") or "",
"unit": row.get("unit"),
"net_amount": row.get("net_amount"),
"total_amount": row.get("total_amount"),
"surcharge_pct": row.get("surcharge_pct"),
"surcharge_note": row.get("note") or "",
"supply_type": row.get("supply"),
"install_by": row.get("install_by"),
"source_structure": row.get("sources") or [],
}
)
return rows
@@ -0,0 +1,177 @@
"""사토 — 실어 내는 줄 (2026-09-08).
**유토곡선이 사토를 내는데 아무도 실어 내지 않고 있었다.** 운반 줄은 `blocks[].bands`
`transfers` 에서만 만들어지는데, 사토는 `residuals(kind="spoil")` 남아 **어느 쪽에도
없다.** 그래서 구조물 잔토를 사토에 얹어도(126.63) **덤프 물량이 하나도 늘었다**
채집석 공제도 사토를 줄이는 값이라 **끝까지 금액에 나타났다**.
실무에는 서는 줄이다 울진 대흥 1공구 토적집계 `D32 사토 1,281`.
**거리는 품셈이 정하지 않는다.** 사토장까지 거리는 설계 입력(`spoil_site_distance_m`)이고,
정했으면 **막고 사유를 낸다** 임의 거리를 넣으면 그대로 금액이 된다.
**지반 갈래는 유토곡선이 것만 쓴다.** 잔량이 갈래별 물량(`ea/rr/br`) 들고 오면
**갈래마다 ** 세운다 덤프 단가가 토사·암으로 갈리기 때문이다. 갈래를 붙인
(`ground_unknown_m3`) **따로 ** 세우고 막는다. 토사로 눅이면 임의 단가가 된다.
"""
from __future__ import annotations
from typing import Any
from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import (
BLOCKED_INPUT_MISSING,
ORIGIN_HAUL,
WorkItemMapping,
)
SPOIL_NAME = "사토 운반"
#: ⚠ **사토장 사면 물량은 안 센다**(2026-09-09 세 창 확인). 교본 6장 3절은 「완료 구간 비탈면을
#: 다듬고 보호공」을 요구하나 **실무 내역·사방공 집계표에 사토장 행이 아예 없다**(비탈 다듬기·
#: 면고르기 둘 다 0). 프로젝트 규칙이 「기본값 = 현행 법령·행정규칙, 교본은 과거 참조」이고
#: 실무가 안 세므로 안 세는 쪽이 기본이다 — **뒤집히면 이 문장만 지우면 된다.**
#: ⚠ **「사토장 정지」도 별도 줄이 아니다** — 실무 단가산출근거가 「사토장정지(굴삭기 0.7㎥)
#: 1/3 적용」으로 **사토 운반 단가 안 조각**으로 넣는다(랩탑 보조 실물 확인). 수량 줄을
#: 따로 세우면 같은 품을 두 번 센다.
SPOIL_SCOPE_NOTE = (
"사토장 정지는 이 단가 안 조각(실무 「사토장정지 1/3 적용」) · 사토장 사면 보호공은"
" 실무 집계표에 행이 없어 안 셈(교본 6장 3절은 요구 — 뒤집으면 그때 셈)"
)
SPOIL_EQUIPMENT = "dump_truck"
#: 거리가 어디서 왔나 — 값 옆에 적는다(사토장에서 온 것과 대체 입력을 가른다).
DISTANCE_FROM_SITE = "사토장 측점까지 누가거리(갈래별 가중평균)"
DISTANCE_FROM_SETTING = "산출 조건의 대체 거리 — 사토장을 놓으면 그 값이 이김"
#: ⚠ **사토장을 놓으면 거리가 저절로 나온다**(2026-09-09 사용자 확정 — 사토장은 이미 있는
#: 측점 위에만 놓이므로 「발생점 → 사토장 측점」 누가거리). 그래서 사유도 「거리가 없다」가
#: 아니라 **「사토장을 아직 안 놓았다」**로 말한다. 설계 입력 칸은 **안 놓았을 때의 대체값**이다.
DISTANCE_MISSING = (
"사토장을 아직 안 놓았고 대체 거리도 없어 값이 서지 않음 — 사토장을 노선에 놓으면"
" 그 측점까지 거리가 계산되고, 그 전에는 산출 조건의 「사토장까지 거리」가 대신 씀"
"(임의 거리를 넣으면 그대로 금액이 됨)"
)
GROUND_UNKNOWN = (
"지반 갈래를 못 붙인 몫 — 구조물 잔토 가운데 걸친 측점의 지반이 섞여 못 가른 것."
" ⚠ 토사로 눅이면 덤프 단가가 임의로 정해짐"
)
#: 잔량이 들고 오는 갈래 키 ↔ 우리 갈래 이름(흙깎기·운반이 쓰는 그 낱말).
GROUND_KEYS = {"ea_m3": "토사", "rr_m3": "리핑암", "br_m3": "발파암"}
def spoil_haul_rows(
haul_table: dict[str, Any] | None, mapping: WorkItemMapping
) -> list[dict[str, Any]]:
"""사토를 실어 내는 줄 하나. 사토가 없으면 줄도 없다."""
spoil = (haul_table or {}).get("spoil") or {}
volume = float(spoil.get("volume_m3") or 0.0)
if volume <= 0:
return []
distance = spoil.get("distance_m")
entry = mapping.for_haul(SPOIL_EQUIPMENT) or {}
code = entry.get("work_item_code")
blocked = distance is None or float(distance) <= 0
reason = DISTANCE_MISSING if blocked else ""
note = spoil.get("note") or ""
# 갈래별로 나눠 세운다 — 덤프 단가가 토사·암으로 갈린다. 갈래가 안 오면 종전처럼 한 줄.
# ⚠⚠ **내역 수량은 자연상태다**(`config_system_design` 5-4-3). 사토 잔량은 유토곡선이
# 쌓은 다짐상태라 되돌린 값(`natural_m3_by_ground`)이 오면 **그 값으로 선다**.
# 갈래를 못 붙인 몫은 되돌릴 계수가 없어 다짐 그대로 서고, 그 사실이 사유에 남는다.
returned = spoil.get("natural_m3_by_ground") or {}
natural_basis = bool(returned)
source_map = returned if natural_basis else (spoil.get("by_ground_m3") or {})
by_ground = {
GROUND_KEYS[key]: float(value)
for key, value in source_map.items()
if key in GROUND_KEYS and float(value or 0.0) > 0
}
state_note = (
"자연상태(유토곡선이 ÷C 로 되돌린 값)"
if natural_basis
else "⚠ 다짐상태 그대로 — 되돌린 값이 아직 안 옴(내역 수량은 자연상태여야 함)"
)
# 갈래별 거리 — 사토장이 놓였으면 그 값이 이긴다(정확한 값이 이김).
by_distance = {
GROUND_KEYS[key]: float(value)
for key, value in (spoil.get("distance_by_ground_m") or {}).items()
if key in GROUND_KEYS and float(value or 0.0) > 0
}
unknown = float(spoil.get("ground_unknown_m3") or 0.0)
if by_ground or unknown > 0:
rows: list[dict[str, Any]] = []
for label, amount in sorted(by_ground.items()):
leg = by_distance.get(label)
leg_blocked = blocked if leg is None else False
rows.append(
_spoil_row(
code,
amount,
distance if leg is None else leg,
leg_blocked,
reason if leg_blocked else "",
note,
ground=label,
extra=" · ".join(
(DISTANCE_FROM_SETTING if leg is None else DISTANCE_FROM_SITE, state_note)
),
)
)
if unknown > 0:
rows.append(
_spoil_row(
code,
unknown,
distance,
True,
GROUND_UNKNOWN,
note,
ground=None,
extra=GROUND_UNKNOWN,
)
)
return rows
return [_spoil_row(code, volume, distance, blocked, reason, note, None, GROUND_UNKNOWN)]
def _spoil_row(
code: str | None,
volume: float,
distance: Any,
blocked: bool,
reason: str,
note: str,
ground: str | None,
extra: str,
) -> dict[str, Any]:
"""사토 운반 줄 하나 — 갈래마다 같은 모양으로 낸다."""
return {
"work_item_code": code,
"name": SPOIL_NAME,
"spec": " · ".join(
part for part in (ground or "", "" if blocked else f"{float(distance):g}m") if part
),
"unit": "",
"quantity": round(volume, 3),
"quantity_gross": None,
"application_ratio_pct": None,
"application_ratio_breakdown": None,
"quantity_breakdown": None,
"ground_class": ground,
"haul_distance_m": None if blocked else float(distance),
"haul_equipment": SPOIL_EQUIPMENT,
"station_from": None,
"station_to": None,
"excavation_method": None,
"spec_detail": " · ".join(part for part in (note, extra, SPOIL_SCOPE_NOTE) if part),
"composite_parts": None,
"structure_kind": None,
"blocked_kind": BLOCKED_INPUT_MISSING if blocked else None,
"blocked_reason": reason,
"variant_axis": None,
"variant_value": None,
"secondary_axes": None,
"spec_class": None,
"spec_class_basis": "",
"composite_not_ready": None,
"in_bill": not blocked and code is not None,
"in_bill_reason": reason,
"origin": ORIGIN_HAUL,
}
@@ -0,0 +1,339 @@
"""구조물 터파기·되메우기·잔토 인계 줄 (2026-09-08).
**빠뜨렸던 자리다.** 구조물 전개는 셋을 `destination: earthwork` 내는데,
토공집계표는 **토적표만** 읽어 만들어져 성분을 아무도 받지 않았다. 그래서 두께 ·
기초 몫을 아무리 맞춰도 **내역서에 줄도 나갔다**(2026-09-08 B09 매김에서 드러남).
실무 내역에는 서는 줄이다 울진 대흥 1공구 토적집계 D12~D14:
`구조물터파기 토사 1,248 / 30 ` · `되메우기 739 `
**품셈 9-13 18구분이다**(토질 3 × 육상/용수 × 심도 3). 축이 서면 자식 코드로
내려간다 토질은 측점 설계값(`design.ground_type`), 심도는 구조물 제원(직고 + 기초 깊이),
용수는 산출 조건 (기본 육상 **통상값**이고 사용자 확정이 아니다). 하나라도 없으면
**지어내지 않고** 상위 코드로 세운 사유를 붙인다.
**잔토는 내역 줄로 세우지 않는다** 사토로 실어 내는 몫이라 유토곡선(B06) 세야 겹치지
않는다. ** 통로는 2026-09-09 생겼다**(`Engine_HaulInputs` 유토곡선 사토 가산
사토 운반 ). 여기서는 값과 사유만 넘기고 `in_bill=False` 둔다 버리면 빠진 줄을
아무도 찾는다.
"""
from __future__ import annotations
from typing import Any
from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import (
BLOCKED_INPUT_MISSING,
BLOCKED_UNIT_DATA_MISSING,
ORIGIN_EARTHWORK,
ORIGIN_STRUCTURE,
WorkItemMapping,
)
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import GROUND_TYPE_LABEL
from common_util.common_util_excavation import (
WALL_BLINDING_DEPTH_M,
WALL_FOUNDATION_DEPTH_M,
)
#: 품셈 9-13 심도 갈래 — 원문 표기(`0~1m · 1~2m · 2~3m`)를 그대로 쓴다.
DEPTH_BANDS: tuple[tuple[float, str], ...] = ((1.0, "0~1m"), (2.0, "1~2m"), (3.0, "2~3m"))
DEPTH_OVER = "3m 초과"
TRENCH_GROUP = "구조물터파기"
RUBBLE_GROUP = "기초잡석"
BACKFILL_GROUP = "되메우기"
SPOIL_GROUP = "잔토처리"
#: ⚠ 남은 축은 **용수 하나**다(2026-09-08). 토질은 측점 설계값(`design.ground_type`)에서
#: 끌어오고, 심도는 구조물 제원(직고 + 기초 깊이)에서 나온다. 용수는 저장에 칸이 없어
#: **입력 칸이 서야 하는 자리**다 — 모른다고 「육상」으로 눅이지 않는다.
WATER_BLOCKED_REASON = (
"용수 유무가 저장에 없어 품셈 9-13 의 18구분 중 육상·용수 어느 쪽인지 못 고름"
" — 모른다고 육상으로 눅이지 않음"
)
GROUND_BLOCKED_REASON = "토질을 못 가름"
#: 품셈 9-13 의 18구분 — **토질 3 × 육상/용수 × 심도 3**. 코드 차례가 원문 그대로다
#: (육상토사 0~1·1~2·2~3 → 용수토사 셋 → 육상 암절취 셋 → … → 용수 발파암 셋).
GROUND_ORDER = ("soil", "ripping_rock", "blasting_rock")
WATER_ORDER = ("육상", "용수")
DEPTH_ORDER = ("0~1m", "1~2m", "2~3m")
#: ⚠ 3m 를 넘는 칸이 **원문에 없다** — 지어내지 않고 상위 코드로 두고 사유를 낸다.
DEPTH_OVER_REASON = "심도 3m 를 넘는 칸이 품셈 9-13 원문에 없음 — 상위 코드로 둠"
#: ⚠ **사용자 확정이 아니라 통상값**이다(2026-09-09 확정 3차 ④). 화면에도 그 사실이 뜨고
#: 사용자가 「용수」로 뒤집으면 코드가 한 칸 옮겨 간다.
WATER_DEFAULT_NOTE = "용수 유무 — 「육상」은 통상값이고 사용자 확정이 아님(확정 3차 ④)"
def trench_child_code(parent: str | None, ground: str | None, water: str, band: str) -> str | None:
"""품셈 9-13 의 18구분 중 한 칸. 축이 하나라도 없으면 `None`(상위 코드로 둔다)."""
if not parent or ground not in GROUND_ORDER or water not in WATER_ORDER:
return None
if band not in DEPTH_ORDER:
return None
index = (
GROUND_ORDER.index(ground) * len(WATER_ORDER) * len(DEPTH_ORDER)
+ WATER_ORDER.index(water) * len(DEPTH_ORDER)
+ DEPTH_ORDER.index(band)
)
return f"{parent}-{index + 1:02d}"
SPOIL_REASON = (
"사토로 실어 내는 몫이라 유토곡선(B06)이 세야 겹치지 않음 — 값을 버리지 않고 사유와"
" 함께 넘김(유토곡선이 사토에 얹어 「사토 운반」 줄로 섬)"
)
#: ⚠ **값 옆에 두는 사실** — 코드 주석에만 두면 화면에서 안 보인다(오늘 여러 번 나온 자리).
#: 되메우기·잔토는 **제자리 기하 부피**로 셈한다. 엄밀히는 되메움에 **쓰이는 흙**이
#: 다짐부피 ÷ C 라 조금 더 들고 그만큼 잔토가 줄어야 한다. **실무 정본도 그냥 빼므로**
#: (터파기 1.55 되메우기 0.30 = 잔토 1.25) 값은 그대로 두고 **사실만 적어 둔다** —
#: 「잔토가 조금 많다」가 나중에 올라오면 이 문장이 답이다.
STATE_NOTE = (
"제자리 기하 부피(자연상태) — ⚠ 되메움에 쓰이는 흙은 다짐부피 ÷ C 라 조금 더 들지만"
" 실무 정본도 그냥 빼므로 그대로 둠"
)
def _depth_band(depth_m: float) -> str:
for limit, label in DEPTH_BANDS:
if depth_m <= limit:
return label
return DEPTH_OVER
def _foundation_depth(options: dict[str, Any]) -> float:
"""기초 깊이 — 「기초유」 0.5 · 「기초버림」 0.1 · 안 고르면 0(비탈분만)."""
value = str(options.get("foundation") or "").strip()
if value == "기초유":
return WALL_FOUNDATION_DEPTH_M
if value == "기초버림":
return WALL_BLINDING_DEPTH_M
return 0.0
def _row(**fields: Any) -> dict[str, Any]:
"""줄 한 벌 — 계약이 요구하는 칸을 **모두** 채운다(빌더마다 같은 모양이라야 한다)."""
row: dict[str, Any] = {
"work_item_code": None,
"name": "",
"spec": "",
"unit": "",
"quantity": 0.0,
"quantity_gross": None,
"application_ratio_pct": None,
"application_ratio_breakdown": None,
"quantity_breakdown": None,
"ground_class": None,
"haul_distance_m": None,
"haul_equipment": None,
"station_from": None,
"station_to": None,
"excavation_method": None,
"spec_detail": "",
"composite_parts": None,
"structure_kind": None,
"blocked_kind": None,
"blocked_reason": "",
"variant_axis": None,
"variant_value": None,
"secondary_axes": None,
"spec_class": None,
"spec_class_basis": "",
"composite_not_ready": None,
"in_bill": True,
"in_bill_reason": "",
"origin": ORIGIN_EARTHWORK,
}
row.update(fields)
return row
def rubble_base_rows(
unit_quantity_table: dict[str, Any], mapping: WorkItemMapping
) -> tuple[list[dict[str, Any]], list[str]]:
"""기초잡석(품셈 12-25) — 구조물 전개가 낸 성분을 **한 줄로** 올린다.
**묶음으로 서는 구조물은 건너뛴다** 옹벽 묶음에 이미 `FP-12-25` 조각이 있어
여기서 세우면 같은 잡석을 센다(타설 줄에서 이미 겪은 자리).
"""
total = 0.0
bases: list[str] = []
for structure in unit_quantity_table.get("structures") or []:
if mapping.composite_for(str(structure.get("type_id") or "")):
continue
for component in structure.get("components") or []:
if str(component.get("name") or "") != RUBBLE_GROUP:
continue
amount = float(component.get("amount") or 0.0)
if amount <= 0:
continue
total += amount
basis = str(component.get("basis") or "")
if basis and basis not in bases:
bases.append(basis)
if total <= 0:
return [], []
entry = mapping.for_earthwork(RUBBLE_GROUP, None)
code = (entry or {}).get("work_item_code")
return [
_row(
work_item_code=code,
name=RUBBLE_GROUP,
spec="구조물",
spec_detail=bases[0] if bases else "구조물 전개 합",
quantity=round(total, 3),
origin=ORIGIN_STRUCTURE,
in_bill=code is not None,
in_bill_reason="" if code else "품셈 공종을 아직 못 이었습니다",
)
], ([] if code else [RUBBLE_GROUP])
def structure_earthwork_rows(
unit_quantity_table: dict[str, Any],
mapping: WorkItemMapping,
water: str | None = None,
) -> tuple[list[dict[str, Any]], list[str]]:
"""구조물이 낸 터파기·되메우기·잔토를 **공종 축**으로 올린다.
`water` 육상·용수. 주면 정함이라 상위 코드로 두고 사유를 낸다.
"""
# ⚠ 키는 **(심도, 토질)** 뿐이다 — 근거 문구까지 키에 넣으면 같은 「암절취 · 2~3m」이
# 측점 문구가 다르다는 이유로 **두 줄로 갈린다**(2026-09-08 실화면에서 그랬다).
trench: dict[tuple[str, str | None], float] = {}
bases: dict[tuple[str, str | None], list[str]] = {}
backfill = 0.0
spoil = 0.0
# ⚠ 터파기 성분이 아예 없는 구조물을 적어 둔다 — **줄이 안 서는 것보다 나쁜 것이
# 「빠진 줄조차 안 보이는 것」**이다(2026-09-09 감사: 옹벽이 터파기 0 줄이었고
# 사유도 안 났다). 관측 원단위로 가는 종류는 표에 터파기 줄이 없으면 이렇게 된다.
without_trench: list[str] = []
for structure in unit_quantity_table.get("structures") or []:
options = structure.get("options") or {}
height = float(structure.get("height_m") or options.get("height_m") or 0.0)
band = _depth_band(height + _foundation_depth(options))
ground = structure.get("ground_type") or None
ground_basis = str(structure.get("ground_type_basis") or "")
for component in structure.get("components") or []:
if str(component.get("destination") or "") != "earthwork":
continue
name = str(component.get("name") or "")
amount = float(component.get("amount") or 0.0)
if amount <= 0:
continue
if name == "터파기":
key = (band, ground)
trench[key] = trench.get(key, 0.0) + amount
reasons = bases.setdefault(key, [])
if ground_basis and ground_basis not in reasons:
reasons.append(ground_basis)
elif name == "되메우기":
backfill += amount
elif name == "잔토처리":
spoil += amount
if not any(
str(component.get("name") or "") == "터파기"
and float(component.get("amount") or 0.0) > 0
for component in structure.get("components") or []
):
without_trench.append(
str(structure.get("name") or structure.get("type_id") or "이름 없는 구조물")
)
rows: list[dict[str, Any]] = []
unmatched: list[str] = []
if trench:
entry = mapping.for_earthwork(TRENCH_GROUP, None)
code = (entry or {}).get("work_item_code")
if code is None:
unmatched.append(TRENCH_GROUP)
order = [band for _, band in DEPTH_BANDS] + [DEPTH_OVER]
for (band, ground), amount in sorted(
trench.items(), key=lambda item: (order.index(item[0][0]), str(item[0][1] or ""))
):
if not amount:
continue
ground_basis = " · ".join(bases.get((band, ground)) or [])
label = GROUND_TYPE_LABEL.get(str(ground), str(ground)) if ground else None
child = trench_child_code(code, ground, str(water or ""), band)
if not ground:
reason = f"{GROUND_BLOCKED_REASON}{ground_basis}"
elif not water:
reason = WATER_BLOCKED_REASON
elif child is None:
reason = DEPTH_OVER_REASON
else:
reason = ""
detail = " · ".join(
part
for part in (
"구조물 전개 합",
ground_basis,
WATER_DEFAULT_NOTE if water == "육상" else "",
)
if part
)
spec = " · ".join(part for part in (label or "", water or "", f"심도 {band}") if part)
rows.append(
_row(
work_item_code=child or code,
name=TRENCH_GROUP,
spec=spec,
ground_class=label,
spec_detail=detail,
quantity=amount,
blocked_kind=BLOCKED_INPUT_MISSING if reason else None,
blocked_reason=reason,
in_bill=not reason,
in_bill_reason=reason,
)
)
if backfill > 0:
entry = mapping.for_earthwork(BACKFILL_GROUP, None)
code = (entry or {}).get("work_item_code")
if code is None:
unmatched.append(BACKFILL_GROUP)
rows.append(
_row(
work_item_code=code,
name=BACKFILL_GROUP,
spec="구조물",
spec_detail=f"구조물 전개 합 · {STATE_NOTE}",
quantity=backfill,
in_bill=code is not None,
in_bill_reason="" if code else "품셈 공종을 아직 못 이었습니다",
)
)
if spoil > 0:
rows.append(
_row(
name=SPOIL_GROUP,
spec="구조물",
spec_detail=f"구조물 전개 합 (터파기 − 되메우기) · {STATE_NOTE}",
quantity=spoil,
in_bill=False,
in_bill_reason=SPOIL_REASON,
)
)
if without_trench:
# 값을 지어내지 않고 **빠졌다는 사실만** 낸다 — 관측 원단위(옹벽·집수정 등)는 표에
# 터파기 줄이 있는 것도 있고 없는 것도 있어, 없으면 그 구조물만 조용히 빠진다.
rows.append(
_row(
name=TRENCH_GROUP,
spec="구조물",
spec_detail=(
"⚠ 터파기가 안 선 구조물: "
+ " · ".join(sorted(set(without_trench)))
+ " — 관측 원단위 표에 터파기 줄이 없음(전개식으로 가는 종류는 높이·두께에서"
" 나오지만 관측표는 표에 있는 줄만 냄). **값을 지어내지 않음** —"
" 실무 내역서에서 그 구조물의 터파기를 어떻게 잡는지 확인할 것"
),
quantity=0.0,
in_bill=False,
in_bill_reason="터파기 물량을 못 세움 — 관측 원단위에 그 줄이 없음",
blocked_kind=BLOCKED_UNIT_DATA_MISSING,
blocked_reason="관측 원단위 표에 터파기 줄이 없음",
)
)
return rows, unmatched
@@ -0,0 +1,146 @@
"""유토곡선이 받아야 할 **구조물 몫** — 채집석 공제와 구조물 잔토 (2026-09-08).
**B08 내기만 하고 빼거나 더하지 않는다.** **양수 ** 주고,
공제(빼기) 사토 가산(더하기) **유토곡선(B06)에서 번씩만** 일어난다.
부호를 넘기면 받는 쪽에서 뒤집힌다 실무 시트가 `274.66` 으로 적혀 있어
실제로 겪은 자리다.
채집석 공제는 사토에서 번만 뺀다.
구조물 잔토는 사토에 번만 더한다.
**측점별로도 낸다.** 총량 하나만 주면 받는 쪽이 잔량 크기에 비례해 나눌 수밖에 없고,
그러면 **운반거리가 틀어진다**( 곳에 몰리면 거리가 어긋남 공제 이미 짚은 자리).
구조물은 구간(start~end)이라 ** 가운데 측점** 자리로 본다.
**지반 갈래도 함께 낸다** 판정이 아니라 **구조물터파기가 이미 쓰는 **이다
(`design.ground_type` 에서 뽑아 암절취 · 심도 2~3m 세운 그것). 터파기에서 나온
흙이 잔토이므로 **같은 판정을 곳이 그대로 쓴다** 벌로 짜면 갈린다.
**섞여서 고른 구조물은 모름(`None`)으로 보낸다** 터파기에서 다수결로 고른
규칙 그대로다. 받는 쪽이 몫을 모르는 으로 드러내면 된다.
**한계도 그대로 넘어간다** 터파기 깊이가 암반 경계선보다 깊은지 보지 않는다.
터파기 토질에 이미 있는 한계이고 여기서 새로 생기는 것이 아니다.
"""
from __future__ import annotations
from typing import Any
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import COLLECTED_STONE_KEY, GROUND_TYPE_LABEL
#: 유토곡선이 읽는 칸 이름 — 양쪽이 같은 낱말을 써야 인계에서 어긋나지 않는다.
STRUCTURE_SPOIL_KEY = "structure_spoil_m3"
STRUCTURE_SPOIL_POINTS_KEY = "structure_spoil_points"
#: ⚠⚠ **이 값은 자연상태(본바닥)다.** 유토곡선 잔량은 **다짐상태**라 그냥 더하면 상태가
#: 섞인다(2026-09-09 랩탑 메인 지적). 받는 쪽이 `× C` 로 맞춰 담을 수 있게 **상태를 값으로
#: 적어 보낸다** — 말로만 두면 다음 사람이 못 본다.
#: 까닭: 터파기·되메우기가 **제자리 기하 부피**(H × 폭 × 연장)이고 잔토는 그 차이다.
#: ⚠ 엄밀히는 되메움에 **쓰이는 흙**은 다짐부피 ÷ C 라 조금 더 든다. 실무 정본 시트도
#: 그냥 빼므로(터파기 1.55 − 되메우기 0.30 = 잔토 1.25) 여기서도 그대로 두되,
#: **그 사실을 적어 둔다** — 값을 임의로 보정하지 않는다.
VOLUME_BASIS_KEY = "volume_basis"
VOLUME_BASIS_NATURAL = "natural"
SPOIL_COMPONENT = "잔토처리"
COLLECTED_STONE_COMPONENT = "채집석"
#: 채집석 공제를 **갈래별로** 낸다 (2026-09-09 세 창 확정 — 축을 맞추기로).
#: ⚠ 이 값은 **벽 입적(제자리 완성 부피)**이다 — 자연·다짐 어느 축으로도 환산한 적 없다.
#: 굳이 가르면 자연 축 쪽이라 받는 쪽이 **×C 로 다짐 축에 맞춰** 뺀다.
#: ⚠⚠ **벽 입적과 원바닥 암 부피의 관계를 정한 원문이 없다** — 캐면 부풀고(L) 벽에 쌓으면
#: 공극이 생기는데 그 값이 품셈·교본·지식DB 어디에도 없다. **실무 시트도 그냥 뺀다**
#: (울진 「채집석 −274.66」이 사토에서 바로 빠진다). 그 가정은 어느 쪽으로 가도 남으므로
#: **가정은 그대로 두고 축만 맞춘다** — 축이 다른 값을 그냥 빼는 것은 우리가 만드는 어긋남이다.
#: ⚠ 갈래를 모르는 구조물 몫은 **환산하지 않는다**(계수가 없다) — 따로 낸다.
COLLECTED_STONE_BY_GROUND_KEY = "collected_stone_by_ground_m3"
COLLECTED_STONE_UNKNOWN_KEY = "collected_stone_ground_unknown_m3"
#: ⭐ **축 맞춤의 검산** — 더하는 쪽(구조물 잔토)과 빼는 쪽(채집석 공제)에 **같은 계수**가
#: 걸리므로 사토 총량은 안 흔들린다. 실측: 잔토 126.625 ×1.15 = 145.62 담기고 공제
#: 64.75 ×1.15 = 74.46 빠져 사토(다짐) 71.16 → ÷1.15 = **61.887**, 축 맞추기 전 61.88 과 같다.
#: ⇒ **값을 안 흔들면서 축만 바로잡은 것**이다. 갈래가 갈리거나 모르는 몫이 섞이는
#: 자리에서만 값이 달라진다.
COLLECTED_STONE_AXIS_CHECK = (
"축 맞춤 검산 — 더하는 쪽과 빼는 쪽에 같은 계수가 걸려 사토 총량은 그대로"
"(잔토 ×C 담김 · 공제 ×C 빠짐 ⇒ ÷C 하면 같은 값)"
)
COLLECTED_STONE_BASIS_NOTE = (
"벽 입적(제자리 부피) — 자연 축으로 보고 받는 쪽이 ×C 로 다짐 축에 맞춰 뺌."
" ⚠ 벽 입적과 원바닥 암 부피의 관계를 정한 원문이 없음(캐면 부풀고 쌓으면 공극이 생기나"
" 그 값이 어디에도 없음). 실무 시트는 그냥 뺌 — 우리는 축만 맞춤"
)
def _center(structure: dict[str, Any]) -> float | None:
start, end = structure.get("start_m"), structure.get("end_m")
if start is None and end is None:
return None
values = [float(v) for v in (start, end) if v is not None]
return sum(values) / len(values)
def haul_inputs(unit_quantity_table: dict[str, Any] | None) -> dict[str, Any]:
"""(채집석 공제, 구조물 잔토, 측점별 잔토). 값이 없으면 `None` — 0 으로 눅이지 않는다.
`None` `0.0` 다르다. 아직 없음 받는 쪽이 갈라 봐야 한다.
"""
if not unit_quantity_table:
return {
COLLECTED_STONE_KEY: None,
COLLECTED_STONE_BY_GROUND_KEY: {},
COLLECTED_STONE_UNKNOWN_KEY: None,
STRUCTURE_SPOIL_KEY: None,
STRUCTURE_SPOIL_POINTS_KEY: [],
VOLUME_BASIS_KEY: VOLUME_BASIS_NATURAL,
}
total = 0.0
points: list[dict[str, Any]] = []
stone_by_ground: dict[str, float] = {}
stone_unknown = 0.0
for structure in unit_quantity_table.get("structures") or []:
amount = 0.0
stone = 0.0
for component in structure.get("components") or []:
name = str(component.get("name") or "")
if name == COLLECTED_STONE_COMPONENT:
stone += float(component.get("amount") or 0.0)
if name != SPOIL_COMPONENT:
continue
amount += float(component.get("amount") or 0.0)
# 채집석 — 그 구조물의 지반 갈래로 담는다. 갈래를 못 가른 구조물 몫은 따로 둔다.
if stone > 0:
ground = str(structure.get("ground_type") or "")
if ground:
stone_by_ground[ground] = stone_by_ground.get(ground, 0.0) + stone
else:
stone_unknown += stone
if amount <= 0:
continue
total += amount
chainage = _center(structure)
if chainage is None:
continue
# 지반 갈래 — 구조물터파기가 쓰는 그 판정을 그대로 싣는다(두 벌로 안 짠다).
ground = structure.get("ground_type") or None
points.append(
{
"chainage_m": chainage,
"spoil_m3": round(amount, 3),
VOLUME_BASIS_KEY: VOLUME_BASIS_NATURAL,
"ground_type": ground,
"ground_label": GROUND_TYPE_LABEL.get(str(ground)) if ground else None,
"ground_basis": str(structure.get("ground_type_basis") or ""),
}
)
collected = unit_quantity_table.get(COLLECTED_STONE_KEY)
return {
COLLECTED_STONE_KEY: collected,
COLLECTED_STONE_BY_GROUND_KEY: {
key: round(value, 3) for key, value in sorted(stone_by_ground.items())
},
COLLECTED_STONE_UNKNOWN_KEY: round(stone_unknown, 3) if stone_unknown else 0.0,
"collected_stone_basis": f"{COLLECTED_STONE_BASIS_NOTE} · {COLLECTED_STONE_AXIS_CHECK}",
STRUCTURE_SPOIL_KEY: round(total, 3) if points or total else None,
STRUCTURE_SPOIL_POINTS_KEY: sorted(points, key=lambda row: row["chainage_m"]),
# 총량에도 상태를 적는다 — 측점값을 안 쓰는 쪽도 상태를 알아야 한다.
VOLUME_BASIS_KEY: VOLUME_BASIS_NATURAL,
}
@@ -17,6 +17,14 @@
인력운반은 `10-6` 소운반 20 m **초과분**이다. 그래서 `in_bill=False` 표시해 넘기고
값은 검산(`무대+도자+덤프 = 운반토량`) 쓴다.
상태(狀態) 개다 **거리는 다짐, 수량은 자연** (2026-09-09)
운반거리의 산정 시에 모든 수량은 다짐상태로 환산하여 계산하고, 내역서에 적용하는
수량은 자연상태로 한다(설계실무 요령 `config_system_design` 5-4-3 인용문).
유토곡선은 다짐상태로 쌓으므로 **가중평균 거리는 그대로 두고**, 내역에 오르는 수량만
`natural_m3`(÷C) 낸다. **환산은 파일 곳에서 번만** 한다 받는 (집계표·인계
) 고르기만 한다. `L`(팽창률) 쓰지 않는다: 품셈 10-11·10-12 `f = 1/L` 안에서
스스로 곱하므로 우리는 **자연상태 물량만 정확히 넘기면 된다**.
입력은 `HaulPlan` 이다 (이미 있는 다시 세지 않는다)
(`bands`)마다 `equipment` · `haul_distance_m` · 지반유형별 물량(`ea_m3`·`rr_m3`·`br_m3`)
들어 있다. 떨어진 구간끼리 옮기는 `transfers` 같은 모양이라 함께 센다.
@@ -27,8 +35,47 @@ from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Iterable
from config.config_system_design import EARTHWORK_CONVERSION_FACTORS
# 지반유형 키 ↔ 표기. `HaulPlan` 이 절토 구간 구성비로 안분해 둔 세 갈래다.
GROUND_LABELS = {"ea_m3": "토사", "rr_m3": "리핑암", "br_m3": "발파암"}
# 표기 ↔ 환산계수 이름(`EARTHWORK_CONVERSION_FACTORS` 의 키).
GROUND_KIND_OF = {"토사": "soil", "리핑암": "ripping_rock", "발파암": "blasting_rock"}
def _factor_of(ground: str) -> float | None:
"""그 갈래의 다짐 환산계수 `C`. 모르면 `None`(받는 쪽이 환산했는지 되짚는 데 쓴다)."""
kind = GROUND_KIND_OF.get(ground)
entry = EARTHWORK_CONVERSION_FACTORS.get(kind) if kind else None
return float(entry["compacted"]) if entry else None
def natural_m3(compacted_volume_m3: float, ground: str) -> float | None:
"""**다짐상태 → 자연상태**(÷ C). 내역서에 오르는 수량은 자연상태다.
근거 `config_system_design` 5-4-3 이미 적혀 있던 문장이다.
운반거리의 산정 시에 모든 수량은 다짐상태로 환산하여 계산하고,
내역서에 적용하는 수량은 자연상태로 한다.
(2021년도 국도건설공사 설계실무 요령 / 표준품셈 계열)
**나누기다.** `C = 다짐 ÷ 자연` 이므로 되돌리려면 나눠야 한다. 곱하면 토사가
1.111배가 아니라 0.9배가 되어 **방향이 뒤집힌다**(거울 시험이 방향을 잠근다).
**`L`(팽창률 1.3·1.35·1.625) 쓰지 않는다.** 우리 곡선은 `×C` 쌓았으니
되돌리는 것도 `C` . 품셈 10-11·10-12 `f = 1/L` ** 안에서 스스로** 곱하므로
우리가 `L` 들면 환산이 된다.
**환산은 내보내는 자리에서 번만.** 곡선 안쪽(·이동·잔량) 다짐상태 그대로 둔다
성토 배분은 다짐으로 세는 것이 맞다.
갈래를 모르면 `None` 이다 토사 계수로 눅이면 근거 없이 금액이 움직인다.
"""
kind = GROUND_KIND_OF.get(ground)
entry = EARTHWORK_CONVERSION_FACTORS.get(kind) if kind else None
if not entry:
return None
factor = float(entry["compacted"])
return compacted_volume_m3 / factor if factor > 0 else None
# 무대 — 품에 포함이라 내역 줄이 되지 않는다.
FREE_HAUL_KEY = "free_haul"
@@ -152,7 +199,13 @@ def build_table(plan: dict[str, Any] | None) -> dict[str, Any]:
{
"equipment": row.equipment,
"ground": row.ground,
# ⚠ 이 칸은 **다짐상태**다 — 운반거리를 낸 그 상태 그대로(검산도 이 값으로 한다).
"volume_m3": row.volume_m3,
"volume_basis": "compacted",
# 내역서에 오르는 수량 = **자연상태**(÷C). 갈래를 모르면 `None`.
"natural_m3": natural_m3(row.volume_m3, row.ground),
"natural_volume_basis": "natural",
"conversion_c": _factor_of(row.ground),
"average_distance_m": row.average_distance_m,
"work_m3m": row.work_m3m,
"legs": row.legs,
@@ -178,12 +231,19 @@ def build_table(plan: dict[str, Any] | None) -> dict[str, Any]:
def summary_input_rows(table: dict[str, Any]) -> list[dict[str, Any]]:
"""토공집계표가 받는 모양으로 줄인다 — 집계표는 근거 줄을 안 쓴다."""
"""토공집계표가 받는 모양으로 줄인다 — 집계표는 근거 줄을 안 쓴다.
** 상태를 함께 넘긴다** 집계표·내역은 `natural_m3`(자연상태) 쓰고,
`volume_m3`(다짐상태) 검산·되짚기용이다. 받는 쪽이 환산하지 않게 이름으로 가른다.
"""
return [
{
"equipment": row["equipment"],
"ground": row["ground"],
"volume_m3": row["volume_m3"],
"volume_basis": row.get("volume_basis") or "compacted",
"natural_m3": row.get("natural_m3"),
"conversion_c": row.get("conversion_c"),
"average_distance_m": row["average_distance_m"],
}
for row in table.get("rows") or []
@@ -201,7 +261,11 @@ class HaulCheck:
def check_against_plan(table: dict[str, Any], plan: dict[str, Any] | None) -> HaulCheck:
"""`무대 + 도자 + 덤프` 합이 `HaulPlan` 의 총 운반량과 맞는가."""
"""`무대 + 도자 + 덤프` 합이 `HaulPlan` 의 총 운반량과 맞는가.
**다짐상태끼리 비교한다** 계획(`HaulPlan`) 다짐이라 자연상태로 환산한 값을 대면
어긋난다. 검산은 환산 (`volume_m3`)으로 하는 것이 맞다.
"""
hauled = sum(float(row.get("volume_m3") or 0.0) for row in table.get("rows") or [])
plan = plan or {}
planned = float(plan.get("hauled_m3") or 0.0) + float(plan.get("transferred_m3") or 0.0)
@@ -165,7 +165,11 @@ class MaterialRow:
if self.surcharge_included:
parts.append(NOTE_INCLUDED)
elif self.surcharge_pct is None:
# ⚠ **왜 미확보인지**를 함께 적는다 — 「표에 이름이 없음」과 「이 방식엔 안 붙임」은
# 할 일이 다르다(2026-09-09 콘크리트에서 갈린 자리).
parts.append(NOTE_RATE_MISSING)
if self.basis:
parts.append(self.basis)
elif self.basis:
parts.append(self.basis)
if self.supply == SUPPLY_OWNER and self.install_by is None:
@@ -215,6 +219,35 @@ def verify_single_surcharge(unit_quantity_table: dict[str, Any] | None) -> list[
return []
#: 할증표 이름과 우리 성분 이름이 **다른 자리** — 이름만 잇는다(값은 그대로).
#: ⚠ 리핑암↔파쇄암 때와 같은 처방이다. 이름을 바꾸면 다른 쪽(타설 줄·묶음 조각)이 어긋난다.
#: 철근: 품셈 1-3-1 의 「이형철근 3 %」는 **규격을 가리지 않는다** — D13·D16 이 같은 줄이다.
#: 콘크리트: **레미콘일 때만** 잇는다. 기계·인력 비빔은 시멘트·골재가 각각 할증되는 자리라
#: 레미콘 할증을 붙이면 틀린다 — 그때는 미확보로 두고 사유를 낸다.
REBAR_ALIASES = {"이형철근 D13": "이형철근", "이형철근 D16": "이형철근"}
CONCRETE_NAMES = ("콘크리트", "채움콘크리트", "버림콘크리트")
READY_MIXED = "ready_mixed"
CONCRETE_ALIAS = "레미콘"
CONCRETE_NOT_READY_NOTE = (
"타설 방식이 레미콘이 아니라 레미콘 할증을 붙이지 않음 — 비빔은 시멘트·골재가 각각 할증됨"
)
def surcharge_lookup_name(name: str, concrete_placing_method: str | None) -> tuple[str, str]:
"""(할증표에서 찾을 이름, 사유). 이름이 그대로면 사유는 빈 문자열이다."""
if name in REBAR_ALIASES:
return REBAR_ALIASES[name], "품셈 1-3-1 「이형철근」 — 규격을 가리지 않음"
if name in CONCRETE_NAMES:
if concrete_placing_method == READY_MIXED:
return CONCRETE_ALIAS, "타설 방식이 레미콘 — 할증표의 「레미콘」 줄로 봄"
# ⚠ **안 정한 것과 비빔을 가른다.** 안 정하면 타설 줄이 기본값(레디믹스트)으로 도는데
# 할증만 미확보로 두면 **같은 프로젝트에서 두 값이 어긋난다**(2026-09-09 실화면).
if not concrete_placing_method:
return CONCRETE_ALIAS, "타설 방식을 안 정해 기본값(레디믹스트)으로 봄 — 정하면 따라감"
return name, CONCRETE_NOT_READY_NOTE
return name, ""
def _collect(
unit_quantity_table: dict[str, Any],
) -> tuple[dict[tuple[str, str], MaterialRow], dict[str, int]]:
@@ -245,6 +278,7 @@ def build_table(
surcharge_table: SurchargeTable | None = None,
supply_map: dict[str, Any] | None = None,
extra_materials: Iterable[dict[str, Any]] = (),
concrete_placing_method: str | None = None,
) -> dict[str, Any]:
"""화면·API 가 그대로 쓰는 모양.
@@ -280,9 +314,10 @@ def build_table(
missing_install_by.append(name)
if row.surcharge_included:
continue
rate, basis = table.rate_for(name)
lookup, alias_note = surcharge_lookup_name(name, concrete_placing_method)
rate, basis = table.rate_for(lookup)
row.surcharge_pct = rate
row.basis = basis
row.basis = " · ".join(part for part in (basis, alias_note) if part)
if rate is None:
missing_rate.append(name)
+30 -8
View File
@@ -45,9 +45,25 @@ NOTE_SECTION_MISSING = (
"그 측점의 횡단 자체가 없습니다 — 관은 놓였는데 횡단이 안 만들어진 자리라 "
"[저장]으로는 안 풀립니다. 횡단설계에서 그 측점이 서야 합니다"
)
#: 관 자리에 횡단이 있는지 볼 때의 허용 오차. **아주 좁게** — 옆 측점을 「있다」로 세면
#: 거짓 안내가 된다. 길이 찾기(0.5m)보다 좁은 것은 뜻이 다르기 때문이다.
SECTION_MATCH_TOLERANCE_M = 0.05
# ── 허용 오차 둘 — **묻는 것이 다르다.** 한 자리에 모아 둔다 ────────────────
# · `LENGTH_MATCH_TOLERANCE_M` — 「이 관의 **길이를 어디서 가져오나**」.
# 넉넉해도 된다. 값을 못 찾는 것보다 옆 측점 길이를 쓰는 편이 낫다.
# · `SECTION_MATCH_TOLERANCE_M` — 「이 측점에 **횡단 설계가 있나**」.
#
# ⚠⚠ **둘 다 0.5 다. 좁히지 말 것** — 앞서 「좁아야 한다」던 판단이 실측으로 뒤집혔다
# (2026-09-09 랩탑 메인). 측점을 만들 때 **정수 미터가 같은 격자 측점이 있으면 그리로
# 스냅**한다(횡단 파일명이 정수 미터라 두 측점이 한 파일을 덮어쓰는 것을 막는 가드).
# 그래서 관 440.241 의 횡단은 **측점 440.0** 이고 구조물 이름표까지 달고 있다 —
# **없는 것이 아니다.** 좁게 보면 「그 측점의 횡단 자체가 없습니다」라는 **거짓 사유**가
# 뜬다(반대 방향의 거짓). 스냅 폭이 「정수 미터 반올림」이라 최대 어긋남이 0.5m 이고,
# 그래서 길이 찾기와 **같은 값**이 된다 — 우연이 아니라 같은 까닭이다.
# ⚠ 값이 같아졌다고 **하나로 합치지 말 것.** 묻는 것이 둘이라 근거도 둘이고, 한쪽 근거가
# 바뀌면 한쪽만 움직여야 한다.
# ⚠ 두 값은 **증상**이지 병이 아니다. 병은 **관 자리에 측점이 없는 것**이고
# (측점을 만드는 자리는 B05 노선 [확정] 한 곳뿐 — 관을 나중에 놓거나 옮기면 안 생긴다,
# 계획서 3-14), 그것이 고쳐지면 이 값들이 **아무 관도 안 건드린다.**
LENGTH_MATCH_TOLERANCE_M = 0.5
SECTION_MATCH_TOLERANCE_M = 0.5
NOTE_KIND_DEFAULT = "관종을 안 정해 기본값({kind})으로 섰습니다 — 정하면 공종이 갈립니다"
NOTE_KIND_UNKNOWN = "{kind}」은(는) 아는 관종이 아니라 공종을 못 골랐습니다"
@@ -72,10 +88,16 @@ def _length_by_chainage(designs: list[dict[str, Any]], key: str) -> dict[float,
return found
def _nearest(lengths: dict[float, float], chainage: float, tolerance: float = 0.5) -> float | None:
def _nearest(
lengths: dict[float, float],
chainage: float,
tolerance: float = LENGTH_MATCH_TOLERANCE_M,
) -> float | None:
"""관 측점과 단면 측점이 소수점에서 어긋날 수 있어 **가까운 것**을 본다.
좁게 본다(기본 0.5m) 넓히면 측점의 길이를 물어 조용히 틀린다.
횡단이 있나 재는 `SECTION_MATCH_TOLERANCE_M`(0.5m) **다른 물음**이다
주석을 . 맞추면 나빠진다.
"""
if not lengths:
return None
@@ -126,10 +148,10 @@ def build_rows(
kind_note = NOTE_KIND_UNKNOWN.format(kind=stored_kind)
length = _nearest(lengths, chainage)
# ⚠ **관이 놓인 그 측점**이 있는지를 본다 — 옆 측점이 있는 것은 소용없다.
# 실측(2026-09-08 `5601e828`): 관 439.55 근처에 측점 440.0 만 있었고, 0.5m 로
# 느슨히 보면 「횡단이 있다」로 읽혀 **「[저장]하면 풀린다」는 거짓 안내**가 떴다.
# 길이는 B06 이 **관이 놓인 측점에만** 싣는다(2026-09-08 이웃 오염을 고친 뒤).
# ⚠ 관이 선 자리의 횡단이 있는지를 본다. **스냅을 셈에 넣는다**(2026-09-09) —
# 관 440.241 의 횡단은 측점 440.0 이고 그것이 정상이다. 옛 주석은 0.05m 로 좁게
# 보라고 했으나, 그때는 **스냅 때문에 관이 측점에 안 붙던 것**을 「측점이 없다」로
# 읽던 시절이라 판단이 뒤집혔다. 길이는 여전히 **주인 측점 하나에만** 실린다.
has_section = not sections or any(
abs(x - chainage) <= SECTION_MATCH_TOLERANCE_M for x in sections
)
+388 -22
View File
@@ -44,10 +44,20 @@ BATTER_MIN_SLOPE_LENGTH_M = 10.0
BATTER_INTERVAL_M = 20.0
LEVEL_MIN_FILL_HEIGHT_M = 5.0
STATUS_COUNTED_ELSEWHERE = "다른 표에서 이미 섬"
STATUS_PENDING = "값을 낼 근거가 없음"
STATUS_NOT_APPLICABLE = "해당 없음"
STATUS_READY = "값 있음"
# ⚠ 상태 낱말은 `_Preparation_Status` 한 벌에서 온다 — 700줄 제한으로 부대시설을 갈라 낼 때
# 두 파일이 같은 문자열을 각자 적으면 조용히 갈릴 자리였다(2026-09-09).
from B08_Quantity.B08_Quantity_Engine_Preparation_Ancillary import ( # noqa: E402
ANCILLARY_ITEMS,
ancillary_rows,
)
from B08_Quantity.B08_Quantity_Engine_Preparation_Status import ( # noqa: E402
STATUS_COUNTED_ELSEWHERE,
STATUS_NOT_APPLICABLE,
STATUS_PENDING,
STATUS_READY,
)
__all__ = ["ANCILLARY_ITEMS", "ancillary_rows"] # 갈라 나간 뒤에도 여기서 읽을 수 있게
def batter_frame_count(slope_rows: Iterable[dict[str, Any]]) -> tuple[int, list[str]]:
@@ -98,12 +108,15 @@ def preparation_rows(
slope_totals: dict[str, float] | None = None,
slope_rows: Iterable[dict[str, Any]] = (),
topsoil_thickness_m: float | None = None,
topsoil_haul_distance_m: float | None = None,
stand_volume_class: str | None = None,
) -> list[dict[str, Any]]:
"""준비공 줄 — 값이 서는 것과 안 서는 것을 **한 목록에** 낸다."""
slope = slope_totals or {}
tree_area = float(slope.get("tree_removal_fill", 0.0)) + float(
slope.get("tree_removal_cut", 0.0)
)
topsoil = _topsoil_row(slope, topsoil_thickness_m)
return [
{
"group": "준비공",
@@ -114,22 +127,24 @@ def preparation_rows(
# ⚠ 값을 여기서 또 내면 같은 나무를 두 번 벤다. 참고로 면적만 보인다.
"reference_amount": tree_area,
"reason": (
# ⚠ **소단면이 이 면적에 들어 있다** — 갈라 내지 않는다(2026-09-09 세 창 확인).
# 별표2 타.(1) 이 「노출되는 절·성토면은 **전체면적**을 녹화」라 소단도 녹화
# 대상이고, 갈라 내라는 원문이 없으며 실무도 한 덩이로 센다. 가르면 「그 몫으로
# 무엇을 세나」를 우리가 정하게 되어 **근거 없는 줄**이 선다. 대신 여기 적어 둔다.
"대상 면적에 **소단면이 포함**됨(별표2 타.(1) 「노출되는 면은 전체면적 녹화」 — "
"원문이 가르지 않고 실무도 한 덩이로 셈). "
"토공집계의 「지장목제거」로 이미 섬 — 여기서 또 세우면 이중계상. "
"⚠ 다만 **공종 미확정** — 품셈 4장이 벌목을 목적별로 갈라(수확베기·단목베기·"
"위험목 베기) 임도 지장목이 어디에 붙는지 원본이 말하지 않음. 지금은 공종코드 없이 감."
"위험목 베기) 임도 지장목이 어디에 붙는지 원본이 말하지 않음."
" 지금은 공종코드 없이 감."
),
"work_item_code": None,
},
_topsoil_row(slope, topsoil_thickness_m),
{
"group": "준비공",
"item": "제근·뿌리다듬기",
"unit": "",
"amount": None,
"status": STATUS_PENDING,
"reason": "단위가 「개」(그루 수)인데 입목 본수를 들고 있지 않음 (품셈 9-20~21)",
"work_item_code": "FP-09-21",
},
topsoil,
# ⚠ 법이 요구하는 **운반·적치** — 제거 물량이 곧 밑수다(별표2).
_topsoil_haul_row(topsoil, topsoil_haul_distance_m),
_root_removal_row(slope, stand_volume_class),
*_root_steps_rows(slope, stand_volume_class),
_batter_frame_row(list(slope_rows)),
_level_frame_row(list(slope_rows)),
]
@@ -142,6 +157,22 @@ def _topsoil_row(slope: dict[str, float], thickness_m: float | None) -> dict[str
쓴다(사면 계열의 면고르기 면적과 같은 자리).
"""
area = float(slope.get("face_dressing_fill", 0.0)) + float(slope.get("face_dressing_cut", 0.0))
# ⚠ 면적이 0 이면 **0 ㎥ 를 「값 있음」으로 내지 않는다** — 0 은 「없음」과 구별이 안 되고,
# 받는 쪽이 「표토가 없는 노선」으로 읽는다. 절·성토 사면적이 0 인 임도는 성립하지
# 않으므로 이 자리는 사실상 **사면표가 아직 안 선 것**이다(2026-09-09 감사에서 잡음).
if area <= 0:
return {
"group": "준비공",
"item": "표토제거",
"unit": "",
"amount": None,
"status": STATUS_PENDING,
"reason": (
"대상 면적이 0 ㎡ 입니다 — 사면표(면고르기 면적)가 아직 서지 않았습니다. "
"0 ㎥ 로 내면 「표토가 없는 노선」으로 읽히므로 값을 세우지 않습니다"
),
"work_item_code": "FP-09-15",
}
if thickness_m is None or float(thickness_m) <= 0:
return {
"group": "준비공",
@@ -150,8 +181,8 @@ def _topsoil_row(slope: dict[str, float], thickness_m: float | None) -> dict[str
"amount": None,
"status": STATUS_PENDING,
"reason": (
"표토 두께가 아직 입력되지 않았습니다 — 품셈 9-15 [주]② 가 두께를 "
"「공식의 입력 변수(T)」로 두어 **품셈이 정하는 값이 아닙니다**. "
"표토 두께가 아직 입력되지 않았습니다 — 두께는 설계가 정하는 값입니다. "
f"{TOPSOIL_ORIGINAL_APPLIED} "
f"산출 조건에서 두께를 넣으면 값이 섭니다 (대상 면적 {area:,.1f}㎡)"
),
"reference_amount": area,
@@ -164,11 +195,327 @@ def _topsoil_row(slope: dict[str, float], thickness_m: float | None) -> dict[str
"unit": "",
"amount": area * thickness,
"status": STATUS_READY,
"reason": f"사면적 {area:,.1f}× 두께 {thickness:g}m (품셈 9-15)",
"reason": (f"사면적 {area:,.1f}× 두께 {thickness:g}m (품셈 9-15) · {TOPSOIL_AREA_GAP}"),
"work_item_code": "FP-09-15",
}
#: ⚠⚠ **원문에 적용값이 있다**(2026-09-09 마스터 원문 대조에서 잡음).
#: 품셈 **9-15-2 답(畓)외구간** 표는 조건을 이렇게 박아 둔다 —
#: `T(표토두께) 0.2m` · `L(운반거리) 20m` · `E 0.4` · 도자(삽날 3.2㎥)
#: ⇒ 앞서 여기 「품셈이 정하는 값이 아니다」라고 적었는데 **그 말이 반쪽이었다.**
#: 두께를 바꾸면 품이 달라지는 것은 맞지만, **원문 적용값은 0.2m 로 적혀 있다.**
#: 지어내지 않되 **원문에 있는 값을 없다고 말하지도 않는다.**
#: ⚠⚠ **그리고 그 품에 20m 압토가 이미 들어 있다** — 실무 내역서(영월 2024)가 정확히
#: 그 모양이다: 「표토제거 답외구간 / M2 · 도자 19Ton · D=20」 **한 줄뿐이고 운반 줄이
#: 따로 없다.** 우리 「표토 운반·적치」 줄은 그래서 **20m 를 넘는 몫**일 때만 새 줄이다.
TOPSOIL_ORIGINAL_APPLIED = (
"⚠ 품셈 9-15-2(답외구간) 원문 적용값은 **T=0.2m · L(운반거리)=20m** 입니다"
" — 값을 자동으로 넣지 않되, 원문에 있는 값이므로 참고할 것"
)
#: ⚠⚠ **대상 면적이 법 문언과 어긋난다**(2026-09-09 준비공 축 감사에서 잡음).
#: 별표2 .2.차.(6) 은 대상을 **「노면·절토대상지」**로 못박고, 성토대상지는 (7) 에서
#: 「표토 등은 **제거·정리**한다」로 따로 두어 **운반·적치 의무를 안 건다.**
#: 우리는 `face_dressing_cut + face_dressing_fill` 로 **절토·성토 사면적을 다 더하고**
#: **노면(연장 × 노폭)은 안 센다** — 두 방향으로 어긋난 셈이다.
#: ㉡ 계열(「또는」을 다 더함) — 성토 사면까지 운반·적치 물량에 실림
#: 빠짐 — 노면 표토가 아예 안 섬(법이 첫째로 든 대상)
#: ⚠ **값을 임의로 고치지 않는다** — 실무가 사면적 한 덩이로 잡는지, 노면을 따로 잡는지가
#: 사용자·실무자 확인 사항이다. 지금은 **사유로 드러내기만** 한다.
TOPSOIL_AREA_GAP = (
"⚠ 대상 면적 확인 필요 — 별표2 는 「노면·절토대상지」로 두는데 우리는 절토·성토"
" **사면적을 다 더하고 노면은 안 셈**. 성토대상지는 별표2 (7) 이 「제거·정리」로만 두어"
" 운반·적치 의무를 안 검. 실무 확인 뒤 대상을 확정할 것"
)
#: 표토 운반·적치 — ⚠ **법이 요구하는데 우리가 제거만 세고 있던 자리**(2026-09-09).
#: 시행규칙 별표2 .2.차.(6)·Ⅰ.3.카.(6):
#: 「노면·절토대상지에 있는 입목…과 그 뿌리, **표토는 전량 제거한 후** 강우 시 유실되거나
#: 경관에 저해되지 않도록 **최고 홍수위보다 높은 장소로 운반하고 쌓아두어야 한다**」
#: ⇒ 제거(9-15)만 세면 **운반이 빠진다.** 물량은 제거 물량 그대로이고 **거리가 설계 입력**이다.
#: ⚠ 거리를 지어내지 않는다 — 비면 막고 사유를 낸다.
TOPSOIL_HAUL_LAW = (
"⚠ 법정 의무 — 시행규칙 별표2 「표토는 전량 제거한 후 … 최고 홍수위보다 높은 장소로"
" 운반하고 쌓아두어야 한다」. 제거만 세면 운반이 빠짐"
" · ⚠⚠ **다만 제거 품(9-15-2)에 이미 L=20m 압토가 들어 있음** — 실무 내역서도"
" 「표토제거 답외구간 / M2」 한 줄뿐이고 운반 줄이 따로 없음. 이 줄은 **20m 를 넘는"
" 몫**일 때만 새 줄이며, 그 가름을 아직 안 함(확인 필요)"
)
#: 제근 — ⚠ **본수가 아니라 임목축적으로 갈린다**(2026-09-09 원문 확인).
#: 품셈 9-21 [주]① 「소림 : 임목축적이 30㎥/㏊ 이상 60 미만 · 중림 : 60 이상 90 미만 ·
#: 밀림 : 90 이상」. ⇒ 물어야 할 값은 **본/ha 밀도가 아니라 축적 등급**이다.
#: ⚠⚠ **그런데 원문 표에 밑수 단위 열이 없다** — 9-20(뿌리다듬기·적재)은 「10주당」이라
#: 적혀 있는데 9-21 표에는 그 표기가 없다. 등급을 넣어도 **무엇당 값인지 모르면 못 센다.**
#: 지어내지 않고 그 사실을 사유로 낸다(원문 확인이 필요한 자리).
STAND_VOLUME_CLASSES = ("소림", "중림", "밀림")
#: ⭐ 2026-09-09 **사용자 확정 5차 6번** — 제근 밑수를 **면적 축**으로 확정.
#: 산림품셈 9-21 표에 밑수 표기가 없어, **건설공사 표준품셈 3-9-2 「1,000㎡당」**을 빌려 쓴다.
#: ⚠ **교차 참조 표시 필수**(CLAUDE.md 3장 — 다른 품셈을 빌려 쓸 때의 규칙).
#: ⇒ 이 확정으로 **제근·뿌리 적재·뿌리 운반·지장목제거 넷이 같은 면적 밑수**로 선다.
ROOT_REMOVAL_BASIS = (
"밑수는 **면적 축**(사용자 확정 5차 6번) — 산림품셈 9-21 에 밑수 표기가 없어"
" ⚠ **교차 참조**: 건설공사 표준품셈 **3-9-2 뿌리뽑기 「1,000㎡당」**을 빌려 씀"
)
ROOT_REMOVAL_CLASS_MISSING = (
"임목축적 등급이 아직 입력되지 않았습니다 — 품셈 9-21 [주]① 이 소림(30~60㎥/㏊)·"
"중림(60~90)·밀림(90 이상)으로 가름. ⚠ 본수가 아니라 **축적**이고, 산림조사부·영림계획에서"
" 옮겨 적는 값이라 프로그램이 만들 수 없음"
)
def _root_removal_row(slope: dict[str, float], stand_volume_class: str | None) -> dict[str, Any]:
"""제근·뿌리다듬기 — 등급과 밑수 단위가 다 서야 값이 난다.
대상 면적은 지장목제거와 같은 자리(벌개제근 연동) 이미 있다 참고로 보인다.
"""
area = float(slope.get("tree_removal_fill", 0.0)) + float(slope.get("tree_removal_cut", 0.0))
picked = str(stand_volume_class or "").strip()
reasons = [ROOT_REMOVAL_BASIS, "대상 면적은 지장목제거와 같은 자리(벌개제근 연동)"]
if picked in STAND_VOLUME_CLASSES:
reasons.append(f"임목축적 등급 「{picked}」 — 품셈 9-21 [주]① 이 품을 그 축으로 가름")
else:
reasons.append(ROOT_REMOVAL_CLASS_MISSING)
# ⚠ 등급은 **품(단가) 갈래**이지 물량 밑수가 아니다 — 면적이 서면 물량은 선다.
# 등급이 비면 값은 서되 **단가를 못 고른다**는 사실만 사유로 남는다.
return {
"group": "준비공",
"item": "제근·뿌리다듬기",
"unit": "",
"amount": area if area > 0 else None,
"status": STATUS_READY if area > 0 else STATUS_PENDING,
"reason": " · ".join(reasons),
"reference_amount": area,
"work_item_code": "FP-09-21",
}
#: ⚠⚠ **품셈이 네 단계로 두었는데 우리가 한 줄만 세고 있었다**(2026-09-09).
#: 9-20 가. 「벌개·제근 → **뿌리다듬기** → **적재** → **운반**」이 시공 과정 표준이고
#: [주] 「본 품에서는 제근 후 **뿌리다듬기와 적재항목**에 적용한다」로 두 항목을 준다.
#: 법도 같은 말을 한다 — 별표2 「입목…과 그 뿌리, 표토는 전량 제거한 후 … **운반하고
#: 쌓아두어야** 한다」. ⇒ 제거만 세면 **적재·운반이 빠진다**(표토에서 이미 겪은 자리).
#: ⚠ 적재는 9-20-2 로 코드가 있고, **운반은 그 장에 공종이 없다** — 10장 계열 어디에 붙는지
#: 원문이 말하지 않는다. 지어내지 않고 사유로 낸다.
ROOT_STEPS_NOTE = (
"품셈 9-20 가. 「벌개·제근 → 뿌리다듬기 → 적재 → 운반」 · 별표2 「그 뿌리…를 전량 제거한 후"
" 운반하고 쌓아두어야 한다」 — 제거만 세면 뒤 단계가 빠짐"
)
#: ⭐ 2026-09-09 **사용자 확정 5차 5번** — 근주이식·임목파쇄는 **기본 안 셈**.
#: ⚠ 다만 「**현장에 따라 파쇄가 적용될 필요 있음**」이라 **임목파쇄만 켤 수 있는 칸**을 둔다.
#: **기본은 꺼짐**이고, **근주이식은 칸도 안 만든다**(켤 자리가 없으면 물을 일도 없다).
#: ⚠ 켜도 **부피는 지어내지 않는다** — 실무는 부피(영월 78㎥ @56,124)로 세고, 그 부피를
#: 우리가 든 곳이 없다. 켜면 줄이 서고 **수량 칸이 비어 사유로 드러난다.**
CHIPPING_ITEM = "임목파쇄"
CHIPPING_CODE = "FP-08-11" # 이동식 임목 파쇄
CHIPPING_OFF_NOTE = (
"기본 안 셈(확정 5차 5번) — 현장에 따라 필요하면 산출 조건에서 켤 것."
" 근주이식(FP-14-02)은 칸도 두지 않음"
)
CHIPPING_ON_NOTE = (
"켜져 있음(확정 5차 5번) — ⚠ **파쇄할 부피(㎥)를 든 곳이 없어** 물량이 안 섬."
" 실무는 부피로 셈(영월 78㎥). 산출 조건에 부피를 넣으면 값이 섬"
)
def chipping_rows(enabled: Any, volume_m3: Any) -> list[dict[str, Any]]:
"""임목파쇄 — **켰을 때만** 줄이 선다. 끄면 줄 자체를 안 낸다.
상태에서 줄을 세우면 ** 칸이 세야 으로 읽힌다**(부대시설과 다른 자리
그쪽은 법정 의무라 줄이 서고, 이쪽은 **셀지 말지가 설계 판단**이다).
"""
if not bool(enabled):
return []
try:
amount = float(volume_m3) if volume_m3 not in (None, "") else None
except (TypeError, ValueError):
amount = None
return [
{
"group": "준비공",
"item": CHIPPING_ITEM,
"unit": "",
"amount": amount,
"status": STATUS_READY if amount and amount > 0 else STATUS_PENDING,
"reason": CHIPPING_ON_NOTE if not amount else "산출 조건에서 넣은 부피 (확정 5차 5번)",
"work_item_code": CHIPPING_CODE,
}
]
def _root_steps_rows(
slope: dict[str, float], stand_volume_class: str | None
) -> list[dict[str, Any]]:
"""뿌리 적재·운반 — **제근과 한 벌로 가는 뒤 단계**. 제근이 서면 함께 선다."""
area = float(slope.get("tree_removal_fill", 0.0)) + float(slope.get("tree_removal_cut", 0.0))
return [
{
"group": "준비공",
"item": "뿌리 적재",
"unit": "",
"amount": area if area > 0 else None,
"status": STATUS_READY if area > 0 else STATUS_PENDING,
"reason": (
f"{ROOT_STEPS_NOTE} · {ROOT_REMOVAL_BASIS}"
" · ⚠ **품셈 9-20-2 는 「10주당」이라 밑수 축이 다름** — 면적 축으로 내고"
" 그 사실을 적음(본수가 서면 그 축으로 옮길 것)"
),
"reference_amount": area,
"work_item_code": "FP-09-20-02",
},
{
# ⭐ 2026-09-09 **확정 5차 4번** — 뿌리 운반은 **덤프**로 잡는다.
# ⚠ 사용자 관찰 「실제 운반품이 적용 안 되는 듯함. 나중엔 적용될 가능성도 있음」
# ⇒ 코드는 붙이되 **품이 안 붙으면 그대로 두고 사유**를 낸다. 억지로 안 붙인다.
# ⚠ 물량 축이 다르다 — 운반은 **부피(㎥)** 인데 우리가 든 것은 **면적**뿐이라
# ㎥ 를 지어내지 않고 면적을 참고로만 싣는다.
"group": "준비공",
"item": "뿌리 운반",
"unit": "",
"amount": None,
"status": STATUS_PENDING,
"reason": (
f"{ROOT_STEPS_NOTE} · 사용자 확정 5차 4번 「**덤프**로 잡음」 —"
" ⚠ 운반 밑수는 **부피(㎥)** 인데 뿌리 부피를 든 곳이 없어 물량이 안 섬"
" (대상 면적만 있음). ⚠ 실무에서 **운반품이 안 붙는 경우가 있음**(사용자 관찰) —"
" 안 붙으면 그대로 두고 이 사유가 남음"
),
"reference_amount": area,
"work_item_code": "FP-10-12",
},
]
def _topsoil_haul_row(topsoil: dict[str, Any], distance_m: float | None) -> dict[str, Any]:
"""표토 운반 — 물량은 제거 물량 그대로, 거리는 설계 입력.
제거 줄이 서면(두께 미입력) 운반도 선다 밑수가 줄이기 때문이다.
"""
amount = topsoil.get("amount")
distance = None
if distance_m is not None:
try:
distance = float(distance_m)
except (TypeError, ValueError):
distance = None
if amount is None:
return {
"group": "준비공",
"item": "표토 운반·적치",
"unit": "",
"amount": None,
"status": STATUS_PENDING,
"reason": f"{TOPSOIL_HAUL_LAW} · 제거 물량이 아직 안 서서 운반도 못 셈(두께 먼저)",
"work_item_code": None,
}
if distance is None or distance <= 0:
return {
"group": "준비공",
"item": "표토 운반·적치",
"unit": "",
"amount": None,
"status": STATUS_PENDING,
"reason": (
f"{TOPSOIL_HAUL_LAW} · 운반거리가 아직 입력되지 않았습니다 — 「최고 홍수위보다"
f" 높은 장소」는 현장에서 정하는 자리라 품셈이 거리를 주지 않습니다"
f" (운반할 물량 {float(amount):,.2f}㎥)"
),
"reference_amount": float(amount),
"work_item_code": None,
}
return {
"group": "준비공",
"item": "표토 운반·적치",
"unit": "",
"amount": float(amount),
"status": STATUS_READY,
"reason": (
f"{TOPSOIL_HAUL_LAW} · 제거 물량 그대로 · 운반거리 {distance:g}m(설계 입력)"
" · 갈래 「토사」로 보냄(표토는 토사임 — 덤프 운반 10-12 는 토사/암절취/발파암으로"
" 갈리고 부모 코드에는 품이 없음)"
),
"work_item_code": "FP-10-12",
"haul_distance_m": distance,
# ⚠ `FP-10-12` 는 **부모**다 — 품이 붙은 것은 잎(10-12-1 토사 · -2 암절취 · -3 발파암)
# 뿐이라 갈래를 안 보내면 B09 가 「후보 3건」만 보이고 금액이 안 선다. 표토가 토사인
# 것은 다툼이 없으므로 여기서 갈래를 실어 보낸다(2026-09-09 마스터 대조에서 잡음).
"variant_axis": "ground_class",
"variant_value": "토사",
}
#: 규준틀 재료 — **세는 것은 확정이고 수량만 몰랐던 자리**(2026-09-09).
#: 품셈 11-2·11-3 [주]④ 「재료량은 **설계수량에 따른다**」 ⇒ 품셈이 값을 안 주는 것이지
#: 「안 센다」가 아니다. 그래서 **제안값을 보이고 사용자가 고치는** 모양으로 둔다
#: (확정 ⑨·⑩ 과 같은 틀 — 「가는 기본값이고 나 선택처럼 동작할 수 있어야 함」).
#: ⚠ **제안값은 실무 관측값이지 법정 기준이 아니다** — 울진 소광 원단위 라이브러리 §8
#: 「규준틀 수평 | 개소 | 각재 50×50 0.0044㎥ · 판재 T12 0.0029㎥ · 못 0.03㎏」.
#: ⚠ **비탈 규준틀 값은 그 시트에 없다** — 수평 값을 준용하고 그 사실을 사유에 적는다.
#: ⚠ **손율은 원문에 있다** — 품셈 11-2 [주]③ 비탈 **50%** · 11-3 [주]③ 수평 **80%**.
FRAME_MATERIAL_SUGGESTED = {
"각재 50×50": (0.0044, ""),
"판재 T12": (0.0029, ""),
"": (0.03, ""),
}
FRAME_MATERIAL_SOURCE = (
"⚠ 실무 관측값(울진 소광 원단위 라이브러리 §8 규준틀 수평) — **법정 기준 아님**."
" 품셈 11-2·11-3 [주]④ 는 「재료량은 설계수량에 따른다」로만 둠. 산출 조건에서 고칠 수 있음"
)
FRAME_LOSS_RATE = {"비탈 규준틀": 50, "수평 규준틀": 80}
def frame_material_rows(
frame_rows: list[dict[str, Any]], overrides: dict[str, Any] | None = None
) -> list[dict[str, Any]]:
"""규준틀 재료 — 개소 × 개소당 수량. **자재 축으로 보낸다.**
개소가 서면 재료도 선다(밑수가 줄이다).
값은 **제안값**이고 산출 조건에서 덮어쓸 있다 사실이 사유에 적힌다.
"""
given = overrides or {}
rows: list[dict[str, Any]] = []
for frame in frame_rows:
count = frame.get("amount")
if not count:
continue
loss = FRAME_LOSS_RATE.get(str(frame.get("item")), None)
for name, (default, unit) in FRAME_MATERIAL_SUGGESTED.items():
raw = given.get(name)
try:
per_ea = (
float(raw) if raw is not None and str(raw).strip() != "" else float(default)
)
except (TypeError, ValueError):
per_ea = float(default)
picked = "산출 조건에서 고른 값" if raw not in (None, "") else "제안값(기본)"
rows.append(
{
"name": name,
"unit": unit,
"amount": float(count) * per_ea,
"destination": "material",
"source": str(frame.get("item") or "규준틀"),
"basis": (
f"{frame.get('item')} {float(count):g}개소 × {per_ea:g}{unit}/개소"
f" ({picked}) · {FRAME_MATERIAL_SOURCE}"
# ⚠ 준용이라는 사실이 상수 주석에만 있고 **화면 근거에는 없던**
# 자리다 — 값이 서면 어디서 온 값인지 안 보인다(2026-09-09 감사).
+ (
" · ⚠ 비탈 규준틀 재료량은 그 시트에 없어 **수평 값을 준용**함"
if str(frame.get("item")) == "비탈 규준틀"
else ""
)
+ (f" · 손율 {loss}%(품셈 [주]③)" if loss else "")
),
}
)
return rows
def _batter_frame_row(slope_rows: list[dict[str, Any]]) -> dict[str, Any]:
"""비탈 규준틀 한 줄. **개소는 원문 기준으로 서고 재료는 미확보**다."""
count, notes = batter_frame_count(slope_rows)
@@ -178,7 +525,11 @@ def _batter_frame_row(slope_rows: list[dict[str, Any]]) -> dict[str, Any]:
"unit": "개소",
"amount": float(count) if count else None,
"status": STATUS_READY if count else STATUS_PENDING,
"reason": ("; ".join(notes) + " · 재료량은 품셈 11-2 [주]④ 「설계수량에 따른다」라 미확보"),
"reason": (
"; ".join(notes)
+ " · 재료량은 품셈 11-2 [주]④ 「설계수량에 따른다」 —"
+ " **제안값(실무 관측)으로 서고 산출 조건에서 고칠 수 있음**"
),
"work_item_code": "FP-11-02",
}
@@ -194,7 +545,8 @@ def _level_frame_row(slope_rows: list[dict[str, Any]]) -> dict[str, Any]:
"status": STATUS_READY if count is not None else STATUS_PENDING,
"reason": "; ".join(notes)
+ (
" · 재료량은 품셈 11-3 [주]④ 「설계수량에 따른다」라 미확보"
" · 재료량은 품셈 11-3 [주]④ 「설계수량에 따른다」"
" **제안값(실무 관측)으로 서고 산출 조건에서 고칠 수 있음**"
if count is not None
else ""
),
@@ -252,10 +604,24 @@ def build_table(
slope_rows: Iterable[dict[str, Any]] = (),
topsoil_thickness_m: float | None = None,
names: dict[str, str] | None = None,
ancillary_counts: dict[str, Any] | None = None,
topsoil_haul_distance_m: float | None = None,
stand_volume_class: str | None = None,
chipping_enabled: Any = False,
chipping_volume_m3: Any = None,
) -> dict[str, Any]:
"""화면·인계가 그대로 쓰는 모양. **못 서는 줄도 목록에 남는다.**"""
rows = preparation_rows(slope_totals, slope_rows, topsoil_thickness_m) + erosion_rows(
structures, names
rows = (
preparation_rows(
slope_totals,
slope_rows,
topsoil_thickness_m,
topsoil_haul_distance_m,
stand_volume_class,
)
+ erosion_rows(structures, names)
+ chipping_rows(chipping_enabled, chipping_volume_m3)
+ ancillary_rows(ancillary_counts)
)
return {
"columns": ["구분", "공종", "단위", "수량", "상태", "사유"],
@@ -0,0 +1,112 @@
"""준비공 — 부대시설·가설공사 다섯 줄.
`B08_Quantity_Engine_Preparation` 에서 갈라 나온 파일이다(2026-09-09, 700 제한).
**내용은 그대로 옮겼고 규칙도 그대로다** 개소를 지어내지 않고, 품셈에 공종이 없음
개소 미입력 갈라 적으며, 값이 없어도 줄은 세운다.
"""
from __future__ import annotations
from typing import Any
from B08_Quantity.B08_Quantity_Engine_Preparation_Status import (
REASON_NO_WORK_ITEM,
STATUS_PENDING,
STATUS_READY,
)
#: 부대시설·가설공사 — **법이 요구하는데 우리가 안 내던 다섯 줄**(2026-09-09 사용자 확정 ⑬).
#: `key` 는 설정의 `ancillary_counts` 칸 이름, `code` 는 품셈 공종(없으면 `None`).
#: ⚠ 다섯 중 **품셈에 공종이 있는 것은 가설창고 하나뿐**이다(마스터 전수 확인).
#: 나머지 넷은 **금액이 못 선다** — 줄은 세우되 「공종 자체가 품셈에 없음」이라고 적는다.
#: 「아직 안 만든 것」과 갈라 적어야 다음에 할 일이 달라진다.
ANCILLARY_ITEMS: tuple[dict[str, Any], ...] = (
{
"key": "national_point_sign",
"item": "국가지점번호판",
"unit": "개소",
"code": None,
"legal": True,
"why": (
"⚠ 법정 의무 — 임도규정 제26조제5항 「국가지점번호판을 제작하여 500미터 마다 "
"설치·관리하되, 필요시 거리를 조정할 수 있으며」. ⚠ 기점 포함·종점 잔여·갈림길 "
"중복을 원문이 정하지 않아 **연장÷500 을 산식으로 쓰지 않는다** — 개소를 넣으면 섬"
),
},
{
"key": "guide_sign",
"item": "임도 안내판",
"unit": "개소",
"code": None,
"legal": True,
"why": (
"⚠ 법정 의무 — 임도규정 제26조제6항 「임도의 시점 및 종점에 안내판…을 설치하여야 "
"한다」. 노선을 이어 가면 최초 시점·최종 종점에 둘 수 있어 **개소는 설계 판단**임"
),
},
{
"key": "gate",
"item": "차단기",
"unit": "개소",
"code": None,
"legal": False,
"why": "임도기술교본 11장(실무 참고) — 법령·행정규칙에는 없음. 설치 개소는 설계 판단",
},
{
"key": "site_container",
"item": "가설창고(컨테이너)",
"unit": "개소",
"code": "FP-11-01",
"legal": False,
"why": "품셈 11-1 콘테이너형 가설건축물 — ⚠ 개소는 현장 조건이라 설계가 정함",
},
{
"key": "flood_supplies",
"item": "수방대책 자재",
"unit": "",
"code": None,
"legal": False,
"why": (
"임도기술교본 11장 — 비닐·말뚝·마대·삽 등을 현장 입구에 비치. "
"품목·수량이 원문에 없어 **한 벌(식)로 받는다**"
),
},
)
#: 품셈에 그 이름의 공종이 아예 없는 줄에 적는 사유. 「아직 안 만든 것」과 갈라 쓴다.
def ancillary_rows(counts: dict[str, Any] | None = None) -> list[dict[str, Any]]:
"""부대시설·가설공사 줄. **개소를 안 넣어도 줄은 선다** — 빠진 것이 보이게.
개소를 **지어내지 않는다**(확정 ). 설정 `ancillary_counts` 넣은 값만 쓴다.
"""
given = {str(key): value for key, value in (counts or {}).items()}
rows: list[dict[str, Any]] = []
for spec in ANCILLARY_ITEMS:
raw = given.get(spec["key"])
try:
amount = float(raw) if raw is not None and str(raw).strip() != "" else None
except (TypeError, ValueError):
amount = None
reasons = [spec["why"]]
if spec["code"] is None:
reasons.append(REASON_NO_WORK_ITEM)
if amount is None:
reasons.append("개소가 아직 입력되지 않았습니다 — 넣으면 물량이 섭니다")
status = STATUS_PENDING
else:
status = STATUS_READY if spec["code"] else STATUS_PENDING
rows.append(
{
"group": "부대시설",
"item": spec["item"],
"unit": spec["unit"],
"amount": amount,
"status": status,
"work_item_code": spec["code"],
"legal_required": bool(spec["legal"]),
"reason": " · ".join(reasons),
}
)
return rows
@@ -0,0 +1,16 @@
"""준비공 줄의 **상태 낱말 한 벌**.
`_Preparation` `_Preparation_Ancillary` 같은 문자열을 각자 적으면 **조용히 갈린다**
(받는 쪽이 `status == "값 있음"` 으로 판정하므로 글자만 달라도 줄이 막힌 것으로 읽힌다).
그래서 700 제한으로 파일을 가를 상수만 여기 둔다(2026-09-09).
"""
from __future__ import annotations
STATUS_READY = "값 있음"
STATUS_PENDING = "값을 낼 근거가 없음"
STATUS_COUNTED_ELSEWHERE = "다른 표에서 이미 섬"
STATUS_NOT_APPLICABLE = "해당 없음"
#: 품셈에 그 이름의 공종이 아예 없는 줄 — 「아직 안 만든 것」과 갈라 적는다.
REASON_NO_WORK_ITEM = "품셈에 그 이름의 공종이 없음 — 금액은 별도 단가로만 설 수 있음"
@@ -86,8 +86,11 @@ def _length_of(slope: StationSlope, series: str, face: str) -> float:
법면보호공은 면고르기를 참조한다 같은 사면길이를 쓴다. 끊고 싶으면 함수만 고친다.
층따기는 성토면만 대상이다.
"""
if series == "bench_cut" and face != "fill":
return 0.0
if series == "bench_cut":
# ⚠ 층따기는 **원지반 표면**을 깎는 일이라 밑수가 성토 비탈면이 아니다
# (교본 6장 4절). B06 설계가 측점마다 내는 값을 그대로 쓴다.
# 없으면 0 — 성토 사면길이로 대신 채우면 **다른 면을 세게 된다**(2026-09-09 정정).
return slope.bench_cut_length_m if face == "fill" else 0.0
return slope.fill_length_m if face == "fill" else slope.cut_length_m
@@ -58,6 +58,12 @@ class StationSlope:
chainage_m: float
cut_length_m: float = 0.0
fill_length_m: float = 0.0
#: 층따기 밑수 — **원지반 표면**의 경사길이(m). B06 설계가 측점마다 낸다
#: (`design.bench_cut_length_m`, 2026-09-09 랩탑 메인).
#: ⚠ **성토 비탈면 길이와 다른 면이다** — 층따기는 성토부 **아래 원지반**을 계단으로
#: 깎는 일이라(교본 6장 4절) 비탈면이 아니라 지표면을 따라간다. 앞서 성토 사면길이를
#: 밑수로 쓰고 있었는데 **면이 달랐다.**
bench_cut_length_m: float = 0.0
berm_width_m: float = 0.0
# 성토고(m) — 성토 사면 조각들의 **수직 낙차 합**. 노면 끝에서 원지반까지 내려간 높이다.
# ⚠ 좌우가 다르면 **큰 쪽**을 쓴다. 「중심점 성토고 5m 이상」(품셈 11-3 [주]①) 판정은
@@ -216,6 +222,8 @@ def station_slope(chainage_m: float, design: dict[str, Any]) -> StationSlope:
chainage_m=float(chainage_m),
cut_length_m=sum(s.length_m for s in segments if s.role == "cut"),
fill_length_m=sum(s.length_m for s in segments if s.role == "fill"),
# ⚠ 없는 측점은 0 이다 — 성토 사면길이로 **대신 채우지 않는다**(면이 다름).
bench_cut_length_m=_num(design.get("bench_cut_length_m")) or 0.0,
fill_height_m=max(fill_by_side.values(), default=0.0),
berm_width_m=_num(berm.get("width_m")) or 0.0,
segments=tuple(segments),
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,593 @@
"""기슭막이 전개 — **형태로 갈라 돌쌓기 식을 그대로 쓴다**(3단계, 2026-09-09).
실무 정본이 근거다 `구조물도/기슭막이/04.구조도(기슭막이).xls` 제목이
**돌기슭막이(H=2.0m, 찰쌓기, 기초유)** 이고, 안의 계산이 돌쌓기와 **같은 **이다.
정면적 비탈면적(×(1+)) 평균두께 입적 · 막자갈 · 고임돌
터파기(기초 + 비탈) 되메우기 잔토
**기슭막이는 식이 아니라 형태가 돌쌓기면 돌쌓기 **이다. 여기서 식을 다시 짜면
같은 계산이 벌이 되어 돌쌓기와 갈린다(CLAUDE.md 5).
**형태가 돌쌓기가 아닌 갈래는 식이 없다** 콘크리트·돌망태·통나무/목재틀·바자.
지어내지 않고 ** 없는지** 줄로 남긴다.
**뒷길이· 종류 칸이 기슭막이 등록부에 아직 없다**(2026-09-09 실측). 돌쌓기 식은 둘로
계수가 갈리는데, 없으면 `_back_length` **조용히 45 돈다**. 값이 나오므로 아무도
알아채는 자리라 ** 사실을 줄로 드러낸다** 값이 있기는 하니 보이는 그것.
"""
from __future__ import annotations
import math
from decimal import ROUND_HALF_UP, Decimal
from typing import Any
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import Component, _num, stone_masonry
#: 형태 → 돌쌓기 갈래(찰이면 True). 여기 없는 형태는 전개식이 없다.
STONE_FORMS: dict[str, bool] = {
"돌쌓기(찰)": True,
"돌쌓기(메)": False,
}
#: 식이 없는 형태와 **왜 없는지**. 품셈 장이 다르거나 원단위가 원문에 없다.
WITHHELD_FORMS: dict[str, str] = {
"콘크리트": (
"콘크리트 기슭막이는 돌쌓기(품셈 13-4)가 아니라 콘크리트 구조물이라 전개식이 다름 "
"— 벽 두께·저판이 정본에 없어 물량이 서지 않음"
),
"돌망태": ("돌망태는 품셈 13-8 이고 규격 축이 망태 치수라 돌쌓기 표를 못 씀 — 원단위 미확보"),
"통나무·목재틀": (
"목재틀은 품셈 13-13 이고 밑수가 ㎥당 목공 품이라 돌쌓기 표를 못 씀 "
"— 각재·판재 자재가 카탈로그에 없어 값이 모자람"
),
"바자": "바자얽기(품셈 5-15)는 별도 일위대가를 만들어 잇기로 확정(2026-09-09 ⑧-4)",
}
#: 돌붙임 원단위 — 소광리 정본 「돌붙임L3=45(사방)」(찰) · 「돌붙임L3=30(야면석메붙임)」(메).
#: ⚠⚠ **돌쌓기 표(13-4)와 합치지 말 것.** 값이 겹쳐 보여 합치고 싶어지는 자리다:
#:
#: 돌쌓기(13-4) 밑수 **비탈면적** · 뒷길이 **일곱 규격** · **기울기 몫 √(1+n²)**
#: 돌붙임(13-5) 밑수 **평면적** · 뒷길이 **넷** · **기울기 몫 없음**
#: 메는 모르타르·채움콘크리트가 통째로 빠진다
#:
#: ⚠ 45㎝ 에서 고임돌 0.15 · 채움 0.20 이 **우연히 같다** — 그것이 합치고 싶어지는 까닭이고,
#: 합치면 뒷길이 25·30·60·75 에서 조용히 틀린다(돌붙임 표에 없는 규격이다).
#:
#: ⚠ **뒷길이는 지금 붙박이다** — 찰 0.45m · 메 0.30m. 정본 탭이 그 둘로만 있고 바닥막이
#: 등록부에 뒷길이 칸이 없다. 칸이 생기면 이 표를 열면 된다.
#: ⓘ 돌중량은 **관측값이 아니라 유도값**이다 — `뒷길이 × 0.77(채움률) × 2.65(비중)`.
#: 찰 0.45 × 0.77 × 2.65 = 0.918 ton/㎡. 메(야면석)는 0.42 로 **원문에 값이 직접 적혀 있어**
#: 같은 유도식이 안 맞는다(야면석은 공극이 커 ㎥당 무게가 다르다) — 그대로 옮긴다.
BED_SILL_FORMS: dict[str, dict[str, Any]] = {
"돌붙임(찰)": {
"back_len_m": 0.45,
"stone_name": "",
"stone_spec": "30×30×45㎝",
"stone_ton_per_m2": 0.45 * 0.77 * 2.65,
"wedge_stone_m3_per_m2": 0.15,
"mortar_m3_per_m2": 0.009,
"fill_concrete_m3_per_m2": 0.2,
"blinding_m3_per_m2": 0.1,
},
"돌붙임(메)": {
"back_len_m": 0.30,
"stone_name": "야면석",
"stone_spec": "20×20×30㎝",
"stone_ton_per_m2": 0.42,
"wedge_stone_m3_per_m2": 0.07,
# 메붙임은 모르타르·채움콘크리트·버림이 **없다**(정본 탭에 줄 자체가 없음).
"mortar_m3_per_m2": None,
"fill_concrete_m3_per_m2": None,
"blinding_m3_per_m2": None,
},
}
#: 돌쌓기 식이 계수를 가르는 데 쓰는 칸 — 기슭막이 등록부에 아직 없는 것을 알린다.
STONE_SPEC_KEYS: tuple[tuple[str, str], ...] = (
("back_len_cm", "뒷길이"),
("stone_kind", "돌 종류"),
)
def revetment(
height_m: float,
length_m: float,
options: dict[str, Any],
face: str | None = None,
face_reason: str = "",
) -> tuple[list[Component], list[str]]:
"""기슭막이 1구간 전개 — 형태가 돌쌓기면 그 식, 아니면 사유만."""
form = str(options.get("form") or "").strip()
if form not in STONE_FORMS:
why = WITHHELD_FORMS.get(form)
if why:
return [], [f"기슭막이 형태 「{form}」 — {why}"]
return [], [
f"기슭막이 형태가 정해지지 않았습니다(지금 「{form or '빈 값'}」) "
"— 형태를 골라야 물량이 섭니다"
]
components, notes = stone_masonry(
height_m, length_m, options, STONE_FORMS[form], face, face_reason
)
# ⚠ 계수를 가르는 칸이 없으면 **기본값으로 조용히 돈다** — 그 사실을 드러낸다.
missing = [label for key, label in STONE_SPEC_KEYS if not options.get(key)]
if missing and components:
notes.append(
f"⚠ 기슭막이 제원에 {' · '.join(missing)} 칸이 없어 **돌쌓기 기본값으로 섰음** "
"— 그 값이 고임돌·야면석·채움콘크리트 계수를 가름"
)
return components, notes
#: 돌골막이 원단위 — 소광리 정본 「골막이(찰)(치수조서연결)」, **개소당**.
#: 「돌-골막이치수」 탭이 개소별 상장·하장·높이를 받아 **평균치수**를 내고, 이 표가 그 평균
#: 하나로 개소당 수량을 낸다. 우리는 개소마다 제 치수로 돌린다 — 밑수가 같으므로 식은 그대로다.
#:
#: ⚠⚠ **돌쌓기(기슭막이) 식과 합치지 말 것.** 넷이 다르다:
#:
#: 정면적 **사다리꼴** (상장+하장)÷2×H − 파형강관 ↔ 기슭막이 직사각 H×1
#: 두께식 (3+0.1H) / (3+0.4H) 의 평균 ↔ 기슭막이 3+0.30 / +0.30(H1)
#: 밑수 **돌쌓기 + 돌붙임** ↔ 기슭막이 돌쌓기만
#: 단위 **개소당** ↔ 기슭막이 m당 (확정 ⑦ 「통일 안 함」)
#:
#: ⓘ 앞서 이 두께식을 「0.45 + 0.1H」로 적어 두었으나 **0.45 는 상수가 아니라 뒷길이 ℓ3**
#: 이다(정본 C26 이 `P6/100` 을 읽는다 — P6 = 45㎝). 두 식 모두 뒷길이 기반이고
#: **더하는 몫만 다르다.**
#: ⓘ 같은 것 — 막자갈 **2/3** · 야면석 **0.88 ton/㎡** · 물구멍 **2㎡/개소 × 0.5m** ·
#: 고임돌 **0.15** · 채움콘크리트 **0.2** 는 두 시트가 같은 값이라 표를 함께 쓴다.
#:
#: ⚠ **엑셀의 자름·반올림을 그대로 옮긴다** — 정본이 줄마다 `INT(x*100)/100` 으로 자르고
#: **다음 줄이 그 잘린 값을 받는다**(막자갈·잔토가 그렇다). 안 자르면 정본과 갈린다.
#:
#: ⚠ **방수로 파형강관을 늘 뺀다** — 정본이 상수 `(0.4 × 0.4) × 3.14` 로 박아 두었고
#: (Ø0.8 파형강관, 울진 관급 목록에 있는 규격) 방수로 치수가 0 인 개소에서도 뺀다.
#: **「방수로 없음」을 고를 칸이 등록부에 없어** 그대로 따른다 — 그 사실을 줄로 낸다.
EROSION_CHECK_DAM = {
"spillway_pipe_r_m": 0.4, # 정본 상수 — 0.4 × 0.4 × 3.14
"spillway_pipe_pi": 3.14,
"thickness_top_per_m": 0.1, # 상부 두께 = 3 + 0.1H
"thickness_bottom_per_m": 0.4, # 하부 두께 = 3 + 0.4H
"rubble_body_ratio": 2 / 3, # 막자갈에서 뺄 몸통 몫
"trench_extra_m": 0.3, # 바닥파기 폭 = 평균두께 + 0.3
"trench_depth_m": 0.5, # 바닥파기 깊이
"default_slope_ratio": 0.3, # 반수면 비탈 1:0.3 — 정본 붙박이
}
#: 골막이가 세우는 형식. 정본 탭이 **찰쌓기 하나**뿐이다.
EROSION_CHECK_FORMS: frozenset[str] = frozenset({""})
#: 나머지 형식과 **왜 없는지**. 지어내지 않는다.
EROSION_CHECK_WITHHELD: dict[str, str] = {
"돌망태": "돌망태는 품셈 13-8 이고 규격 축이 망태 치수라 돌쌓기 표를 못 씀 — 원단위 미확보",
"콘크리트": "콘크리트 골막이는 몸체 두께·저판이 정본에 없어 물량이 서지 않음",
"통나무": (
"통나무 골막이는 품셈 13-13(목재틀)이고 밑수가 ㎥당 목공 품이라 축이 다름 — 원단위 미확보"
),
"바자": "바자얽기(품셈 5-15)는 별도 일위대가를 만들어 잇기로 확정(2026-09-09 ⑧-4)",
"": "흙골막이는 정본에 탭이 없고 품셈 장도 다름 — 원단위 미확보",
}
# ── 엑셀 자름·반올림 ────────────────────────────────────────────────────────
# ⚠⚠ **걷어 내지 말 것.** 「자릿수 맞추기」로 보여 지우고 싶어지는 자리인데, 정본이
# **줄마다 자르고 다음 줄이 그 잘린 값을 받는다** — 표시용이 아니라 **계산의 일부**다.
# 입적 = INT(정면적 × 평균두께) ← 평균두께가 이미 잘린 값(1.09)
# 막자갈 = INT(입적 (…)) ← 입적이 이미 잘린 값(20.56)
# 잔토 = ROUND(바닥파기 − (…)) ← 바닥파기가 이미 잘린 값(8.61)
# 안 자르면 막자갈이 10.55, 잔토가 2.75 로 나와 **정본과 갈린다.**
# 「정본과 같은 값」의 뜻을 **소수점까지**로 잡은 것이고, 거울 시험이 그것을 잡는다.
def _floor2(value: float) -> float:
"""엑셀 `INT(x*100)/100` · `ROUNDDOWN(x,2)`."""
return math.floor(value * 100) / 100
def _round2(value: float) -> float:
"""엑셀 `ROUND(x,2)`.
파이썬 `round` **짝수 반올림**이라 4.905 4.90 으로 내린다(엑셀은 4.91).
물구멍관이 정확히 값이라 바꾸면 정본과 갈린다.
"""
return float(Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
def _ceil2(value: float) -> float:
"""엑셀 `ROUNDUP(x,2)` — 야면석만 이쪽이다(정본이 그 줄만 올림)."""
return math.ceil(value * 100) / 100
def erosion_check_dam(
height_m: float, options: dict[str, Any]
) -> tuple[list[Component], list[str]]:
"""돌골막이 1개소 전개 — 정본 「골막이(찰)(치수조서연결)」의 식을 그대로 돌린다."""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import (
DESTINATION,
STONE_BACK_LENGTH_TABLE,
STONE_MASONRY,
_back_length,
fill_concrete_mpa,
stone_weight_per_m2,
weep_hole_spec,
)
form = str(options.get("form") or "").strip()
if form not in EROSION_CHECK_FORMS:
why = EROSION_CHECK_WITHHELD.get(form)
if why:
return [], [f"골막이 형식 「{form}」 — {why}"]
return [], [
f"골막이 형식이 정해지지 않았습니다(지금 「{form or '빈 값'}」) "
"— 형식을 골라야 물량이 섭니다"
]
top_m = _num(options.get("top_length_m"), 0.0)
bottom_m = _num(options.get("bottom_length_m"), 0.0)
if top_m <= 0 or bottom_m <= 0 or height_m <= 0:
return [], [
"골막이 정면적이 **사다리꼴**이라 상장ⓐ·하장ⓑ·높이가 다 있어야 셈이 섭니다 "
f"(지금 상장 {top_m:g}m · 하장 {bottom_m:g}m · 높이 {height_m:g}m)"
]
const = EROSION_CHECK_DAM
notes: list[str] = []
back_cm = _back_length(options)
coeff = STONE_BACK_LENGTH_TABLE.get(back_cm)
if coeff is None:
return [], [f"뒷길이 {back_cm}㎝ 는 품셈 표(25·30·35·45·55·60·75㎝)에 없어 계수가 없습니다"]
if not any(options.get(key) is not None for key in ("back_len_cm", "stone_back_length_cm")):
notes.append(
f"⚠ 뒷길이를 안 골라 기본 {back_cm}㎝ 로 섰습니다 "
"— 그 값이 두께식·고임돌·돌 무게·채움콘크리트 계수를 모두 가릅니다"
)
back_m = back_cm / 100.0
given = _num(options.get("face_slope_ratio"), 0.0)
if given > 0:
slope, slope_note = given, f"사용자 지정 1:{given:g}"
else:
slope = const["default_slope_ratio"]
slope_note = f"1:{slope:g} — 정본 「반수면비탈」 붙박이(안 정함)"
# ① 정면적(사다리꼴) − ② 방수로 파형강관 단면
# ⚠ 「없음」이면 안 뺀다 — 정면적이 밑수라 **열한 줄이 통째로 움직인다.**
# ⓘ 정본은 방수로 치수가 0 인 개소에서도 뺐다. 그러므로 **정본과 같은 값은 「있음」**이고,
# 「없음」은 정본보다 크게 나온다. 안 고르면 정본 쪽(「있음」)으로 서고 그 사실을 알린다.
trapezoid = _floor2((top_m + bottom_m) / 2 * height_m)
spillway = str(options.get("spillway") or "").strip()
pipe = const["spillway_pipe_r_m"] ** 2 * const["spillway_pipe_pi"]
if spillway == "없음":
front_area = trapezoid
notes.append("방수로 「없음」이라 파형강관 단면을 안 뺐습니다 — 정본보다 정면적이 큽니다")
else:
front_area = trapezoid - pipe
notes.append(
f"방수로 파형강관 {pipe:.4f}㎡ 를 정면적에서 뺐습니다 — 정본 붙박이(Ø0.8)"
+ ("" if spillway else " · ⚠ 방수로를 안 골라 정본대로 뺐습니다")
)
masonry = _floor2(front_area * _round2(math.hypot(slope, 1.0)))
top_t = _floor2(back_m + const["thickness_top_per_m"] * height_m)
thickness = _floor2(
(
(back_m + const["thickness_top_per_m"] * height_m)
+ (back_m + const["thickness_bottom_per_m"] * height_m)
)
/ 2
)
volume = _floor2(front_area * thickness)
facing = top_m * (top_t - back_m)
base_area = masonry + facing
rows: list[tuple[str, str, float, str]] = [
("돌쌓기", "", masonry, f"정면적 {front_area:.4f}× √(1+n²) · 기울기 {slope_note}"),
("돌붙임", "", facing, f"상장 {top_m:g}m × (상부두께 {top_t:g} 뒷길이 {back_m:g})"),
(
"입적",
"",
volume,
f"정면적 × 평균두께 {thickness:g}m "
f"[{{(3+0.1H)+(3+0.4H)}}÷2 · 3={back_m:g} · H={height_m:g}]",
),
]
# 돌 무게 — ⭐ 확정 5차 큰 것 7. 돌쌓기와 **같은 헬퍼**를 쓴다(규칙이 하나여야 한다).
kind = str(options.get("stone_kind") or "").strip()
stone_name, stone_ton, weight_tail, _src = stone_weight_per_m2(
back_cm, kind, coeff["stone_ton_per_m2"]
)
if stone_ton is None:
notes.append(f"뒷길이 {back_cm}㎝ 에 {stone_name} 중량 칸이 비어 있어 돌을 못 세웠습니다")
else:
rows.append(
(
stone_name,
"ton",
_ceil2(base_area * stone_ton),
f"(돌쌓기+돌붙임) {base_area:.4f}{weight_tail}",
)
)
wedge_per = coeff["wedge_stone_m3_per_m2"]
wedge = _round2(base_area * _num(wedge_per)) if wedge_per is not None else 0.0
if wedge_per is None:
notes.append(f"뒷길이 {back_cm}㎝ 에 고임돌 칸이 비어 있습니다 — 막자갈에서 안 뺐습니다")
else:
rows.append(("고임돌", "", wedge, f"(돌쌓기+돌붙임) × {wedge_per} ㎥/㎡"))
rows.append(
(
"막자갈",
"",
_floor2(volume - (base_area * back_m * const["rubble_body_ratio"] + wedge)),
"입적 − ((돌쌓기+돌붙임) × 뒷길이 × 2/3 + 고임돌)",
)
)
hole_area, hole_dia, hole_basis = weep_hole_spec(options)
rows.append(
(
"물구멍관",
"m",
_round2(masonry / hole_area * STONE_MASONRY["weep_hole_length_m"]),
f"돌쌓기 ÷ {hole_area:g}㎡/개소 × {STONE_MASONRY['weep_hole_length_m']:g}m/개소"
f" · {hole_basis}{hole_dia})",
)
)
# 바닥파기 — 비탈 사면장 두 쪽 + 하장 한 쪽, 폭은 평균두께 + 0.3.
slant = _floor2(math.hypot((top_m - bottom_m) / 2, height_m))
width = thickness + const["trench_extra_m"]
depth = const["trench_depth_m"]
trench = _floor2(slant * depth * width * 2 + bottom_m * depth * width)
spoil = _round2(trench - (slant * depth * width + bottom_m * depth * width))
rows.extend(
[
(
"터파기",
"",
trench,
f"(사면장 {slant:g} × {depth:g} × {width:g}) × 2 + (하장 {bottom_m:g} × "
f"{depth:g} × {width:g}) · 정본 이름 「바닥파기」",
),
("잔토처리", "", spoil, f"바닥파기 − (사면장 + 하장) × {depth:g} × {width:g}"),
("되메우기", "", trench - spoil, "바닥파기 잔토"),
]
)
components = [
Component(name, unit, amount, DESTINATION.get(name, "quantity"), basis)
for name, unit, amount, basis in rows
]
mpa, mpa_basis = fill_concrete_mpa(options)
fill_per = coeff["fill_concrete_m3_per_m2"]
components.append(
Component(
"채움콘크리트",
"",
_round2(base_area * _num(fill_per)),
DESTINATION["채움콘크리트"],
f"(돌쌓기+돌붙임) × {fill_per} ㎥/㎡ · {mpa_basis}",
spec=f"{mpa}",
)
)
if not kind:
notes.append(
"돌 종류를 안 골라 **계산식**(뒷길이 × 0.77 × 2.65)으로 섰습니다 "
"— 정본 탭은 야면석이고, 야면석은 계산식이 안 맞아 관측표로 갈립니다"
)
return components, notes
#: 개거(겉도랑) 원단위 — 소광리 정본 「개거(150-200) (2)」·「L형수로-(201)」, **둘 다 m당**.
#: ⚠⚠ **두 표가 담는 것이 다르다.** 값이 비슷해 보여 합치고 싶어지는 자리다:
#:
#: 콘크리트 개거 150×200 터파기 · 유로폼 · 면목 **세 줄뿐** — **콘크리트 본체 줄이 없다**
#: 콘크리트 L형수로 H=0.2 터파기 · 되메우기 · 잔토 · **콘크리트** · PVC Φ50 · 이형철근 D13 ·
#: 거푸집 · 면목 **여덟 줄**
#:
#: ⓘ 같은 것 — **둘 다 m당**이고 거푸집 계열(유로폼·거푸집)과 면목이 있다.
#: ⚠ 개거 표에 콘크리트가 없는 것은 **빠뜨린 것이 아니라 원문 그대로**다. 0 으로 때우거나
#: L형수로 값을 옮겨 채우지 않는다(2026-09-09 판단 — 돌붙임(메)에서 세 줄을 안 만든 그 자리).
#:
#: ⓘ **「다른 탭에 본체가 있나」를 찾아봤고 없었다**(2026-09-09 53탭 전수):
#: · 「개거」 글자가 나오는 탭은 **둘뿐** — 이 탭과 「콘크리트개거」.
#: 뒤엣것은 **제목만 「콘크리트개거 (물넘이형)」이고 내용이 국가지점번호판 표**다.
#: · 탭 이름의 「(2)」는 **짝 번호가 아니라 엑셀 사본 표식**이다 — `Φ1000` ↔ `Φ1000(2)`,
#: `떼수로(윤주1.08)` ↔ `…(2)` 가 **내용까지 똑같다**(대조 확인). 「(1)」은 없다.
#: · 같은 시트의 「측구수로400/500」도 제목이 **「터파기 계산서」**이고 본체가 없다 —
#: **한 구조물을 부분 계산서로 쪼개는 버릇**이 있는 시트다.
#: ⇒ 그러므로 이 표는 **부분 계산서일 가능성이 남아 있다.** 다만 이 원본 안에서는
#: 본체 표를 못 찾았으므로 **「원문에 없음」으로 두고 그 사실을 화면에 적는다** —
#: 「콘크리트 개거인데 콘크리트가 0」이 우리 결함으로 읽히지 않게.
#:
#: ✅ **규격 칸이 생겼다**(2026-09-09 랩탑 메인 `0b0763ed`) — 두 규격을 고를 수 있고,
#: 비우면 「콘크리트 개거 150×200」으로 선다.
OPEN_DITCH_FORMS: dict[str, dict[str, Any]] = {
"콘크리트 개거 150×200": {
"rows": (
("터파기", "", 0.3 * 0.1 * 1.0, "0.3 × 0.1 × 1"),
("유로폼", "", (0.2 + 0.2 + 0.15) * 1.0, "(0.2 + 0.2 + 0.15) × 1"),
("면목", "m", 2.0 * 1.0, "2 × 1"),
),
},
"콘크리트 L형수로 H=0.2": {
"rows": (
("터파기", "", 0.31645, "[{(1.11+1.51)÷2×0.2} + {(0.33×0.33)÷2}] × 1.0"),
("되메우기", "", 0.129975, "(0.2+0.30)÷2×0.25×1 + (0.2+0.19)÷2×0.33×1.0"),
("잔토처리", "", 0.186475, "터파기 되메우기"),
(
"콘크리트",
"",
0.1508,
"{(0.2+0.15)÷2×0.7} + (0.21×0.13)×1 + {(0.15+0.21)÷2×0.20}×1",
),
("이형철근 D13", "kg", 0.398, "0.2 × 2 × 0.995"),
("거푸집", "", 0.738, "0.2 + 0.33 + √(0.2² + 0.06²)"),
("면목", "m", 1.0, "1"),
),
# ⚠ 정본에 줄은 있으나 **수량 칸이 비어 있다**(PVC Φ50). 0 으로 만들지 않고 알린다.
"blank_rows": (("PVC 파이프 Φ50㎜", "m"),),
},
}
#: 떼흙막이 원단위 — 소광리 정본 「떼흙막이」, **개소당**. 치수는 전부 **평균 붙박이**다
#: (상단 1.5 · 하단 1.1 · 높이 0.5 · 두께 0.2 · 기슭 0.54 — 원문에 「(평균)」이라 적혀 있다).
#: ✅ **형식 칸에 「떼」가 들어와 이 표가 선다**(2026-09-09 랩탑 메인 `513aff3e`).
#: ⚠ 나머지 일곱 형식은 **정본에 탭이 없다.** 돌(찰)/(메)에 기슭막이 식을 빌려 쓰지 않는다 —
#: 흙막이는 교본상 앞면 1:0.3·뒷면 수직·천단 30㎝ 라 축이 다르고, 무엇보다 **근거가 없다.**
#: ⚠⚠ **사용자 확정 4차**(2026-09-09) — 「흙막이는 횡단도에서 표현방식·옵션이 기슭막이와
#: 동일, 형상도 동일. 다만 **데이터는 분리하여 계산**」. ⇒ **그림·옵션은 한 벌로 쓰되
#: 수량은 여기서 제 것으로 낸다.** 그림이 같다고 기슭막이 원단위를 끌어오면 그 확정을 어긴다.
SOIL_GUARD_SOD: dict[str, Any] = {
"unit_label": "개소당",
"rows": (
(
"",
"",
1.39,
"머리떼 1.5×0.2 + 바닥떼 1.1×0.2 + 정면떼 (1.5+1.1)÷2×0.5 "
"+ 양기슭바닥떼 0.54×0.2×2 · 규격 20×20㎝",
),
# ⚠ 이름을 **터파기**로 낸다 — 정본 이름은 「바닥파기」이나 그대로 두면 토공 축에
# 안 실린다(`DESTINATION` 이 이름으로 가른다). 골막이도 같은 자리를 그렇게 냈다.
("터파기", "", 0.17, "(0.54×0.4×0.2×2) + (1.1×0.4×0.2) · 정본 이름 「바닥파기」"),
),
}
def soil_guard(options: dict[str, Any]) -> tuple[list[Component], list[str]]:
"""흙막이 전개 — **「떼」만 선다**(정본 「떼흙막이」, 개소당). 나머지는 사유만.
치수가 **전부 평균 붙박이** 정본 표에 (평균)이라 적혀 있고 개소별 치수조서가
따로 없다. 그래서 높이·길이를 받지 않는다. 개소별로 갈리게 하려면 치수 칸이 먼저다.
"""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import DESTINATION
form = str(options.get("form") or "").strip()
if not form:
return [], ["흙막이 형식이 정해지지 않았습니다 — 형식을 골라야 물량이 섭니다"]
if form != "":
return [], [
f"흙막이 형식 「{form}」 — 정본(소광리 53탭)에 그 형식의 산출식이 없습니다"
"(있는 것은 **떼흙막이** 하나). ⚠ 돌(찰)·돌(메)에 기슭막이 식을 빌려 쓰지 않습니다 "
"— 흙막이는 앞면 1:0.3·뒷면 수직·천단 30㎝(교본)라 축이 다르고 근거가 없습니다"
]
components = [
Component(name, unit, amount, DESTINATION.get(name, "quantity"), basis)
for name, unit, amount, basis in SOIL_GUARD_SOD["rows"]
]
return components, [
f"떼흙막이는 **{SOIL_GUARD_SOD['unit_label']}**이고 치수가 정본 평균 붙박이입니다"
"(상단 1.5 · 하단 1.1 · 높이 0.5 · 두께 0.2 · 기슭 0.54m) — 개소 제원을 안 봅니다"
]
def open_ditch(length_m: float, options: dict[str, Any]) -> tuple[list[Component], list[str]]:
"""개거(겉도랑) 전개 — **m당** 원단위를 연장에 곱한다(소광리 정본 두 탭).
규격을 고를 칸이 없어 지금은 **콘크리트 개거 150×200 붙박이**. 사실을 줄로 낸다.
"""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import DESTINATION
if length_m <= 0:
return [], ["연장이 없어 전개하지 않음"]
spec = str(options.get("ditch_spec") or "콘크리트 개거 150×200").strip()
table = OPEN_DITCH_FORMS.get(spec)
if table is None:
return [], [
f"개거 규격 「{spec}」 원단위가 없습니다 — 있는 것: {' · '.join(OPEN_DITCH_FORMS)}"
]
components = [
Component(
name,
unit,
per_m * length_m,
DESTINATION.get(name, "quantity"),
f"{basis} × 연장 {length_m:g}m (m당 {per_m:g})",
)
for name, unit, per_m, basis in table["rows"]
]
notes: list[str] = []
if not options.get("ditch_spec"):
notes.append(f"규격을 안 골라 「{spec}」으로 섰습니다 — 제원에서 고를 수 있습니다")
if spec == "콘크리트 개거 150×200":
notes.append(
"⚠ 이 표에는 **콘크리트 본체 줄이 없습니다** — 정본 원문 그대로입니다"
"(터파기·유로폼·면목 세 줄뿐). 같은 원본 53탭에서 본체 표를 찾았으나 없었고"
"(「콘크리트개거」 탭은 제목만 그 이름이고 내용이 다른 표), 이 시트가"
"「측구수로 터파기 계산서」처럼 **부분 계산서를 쓰는 버릇**이 있어"
"**다른 문서에 본체가 있을 수 있습니다.** 지어내지 않았습니다"
)
for name, unit in table.get("blank_rows", ()):
notes.append(f"{name}({unit}) 은 정본에 줄은 있으나 **수량이 비어 있어** 안 세웠습니다")
return components, notes
def bed_sill(area_m2: float, options: dict[str, Any]) -> tuple[list[Component], list[str]]:
"""바닥막이 전개 — **돌붙임 ㎡당** 원단위를 면적에 곱한다(소광리 정본 두 탭).
밑수가 **면적**이다. 돌쌓기처럼 높이·연장으로 셈하지 않는다 정본 제목이 이고
등록부 밑수도 `area_m2` 축이 그대로 맞는다.
기울기 몫이 **없다** 평면에 붙이는 것이라 (1+) 곱하지 않는다.
"""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import DESTINATION
form = str(options.get("form") or "").strip()
table = BED_SILL_FORMS.get(form)
if table is None:
return [], [
f"바닥막이 형태 「{form or '빈 값'}」 — 돌붙임(찰)·돌붙임(메)만 원단위가 있습니다"
]
if area_m2 <= 0:
return [], ["면적이 없어 전개하지 않음"]
back = table["back_len_m"]
rows: list[tuple[str, str, float, str]] = [
("돌붙임", "", area_m2, f"면적 {area_m2:g}㎡ (평면적 — 기울기 몫 없음)"),
("입적", "", area_m2 * back, f"면적 × 두께 {back:g}m"),
(
table["stone_name"],
"ton",
area_m2 * table["stone_ton_per_m2"],
f"면적 × {table['stone_ton_per_m2']:.3f} ton/㎡ · {table['stone_spec']}"
+ (
f" (= 뒷길이 {back:g} × 0.77 × 2.65)"
if form == "돌붙임(찰)"
else " (원문 직접값 — 야면석은 유도식이 안 맞음)"
),
),
(
"고임돌",
"",
area_m2 * table["wedge_stone_m3_per_m2"],
f"면적 × {table['wedge_stone_m3_per_m2']} ㎥/㎡",
),
]
for name, key, unit in (
("모르터", "mortar_m3_per_m2", ""),
("채움콘크리트", "fill_concrete_m3_per_m2", ""),
("버림콘크리트", "blinding_m3_per_m2", ""),
):
value = table[key]
if value is not None:
rows.append((name, unit, area_m2 * value, f"면적 × {value} ㎥/㎡"))
# 터파기 — 정본은 「두께 × 1 × 1」이라 **면적 × 두께**와 같다.
rows.append(("터파기", "", area_m2 * back, f"면적 × 두께 {back:g}m"))
components = [
Component(name, unit, amount, DESTINATION.get(name, "quantity"), basis)
for name, unit, amount, basis in rows
]
notes = [f"뒷길이 {back:g}m 는 정본 탭 붙박이 값입니다 — 바닥막이 제원에 뒷길이 칸이 없습니다"]
if form == "돌붙임(찰)":
notes.append(
"버림 콘크리트 0.1 ㎥/㎡ 포함 — 정본 주기 「바닥 10㎝ 이상 콘크리트(버림) 포설 후 "
"돌붙임」(KDS 44 90 00 의 100㎜ 와 같음)"
)
return components, notes
+214 -2
View File
@@ -49,6 +49,7 @@ from common_util.common_util_project_settings import (
)
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import run_with_connection
from config.config_system_design import EARTHWORK_CONVERSION_FACTORS
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B08 Quantity"])
@@ -83,6 +84,10 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
settings, project_root = await _project_settings(project_id)
plan = await _stored_haul_plan(project_id, route_id)
haul = build_haul_table(plan)
# 사토 — **운반 줄이 되는 값**인데 유토곡선의 띠·이동에는 안 들어 있다(잔량으로 남는다).
# 여기서 그 값을 운반표에 실어 인계가 「사토 운반」 한 줄을 세우게 한다.
# ⚠ 거리는 품셈이 정하지 않는다 — 설계 입력(`spoil_site_distance_m`)이고 없으면 막힌다.
haul["spoil"] = _spoil_of(plan, settings, _spoil_sites(designs))
# 배수관 연장 — B06 이 측점 `design.pipe_length_m` 에 남긴 값. **여기서 짓지 않는다.**
# 인계가 관 줄을 세울 때 쓴다. 단면을 두 번 읽지 않으려고 이 응답에 실어 보낸다.
table["pipe_lengths"] = [
@@ -131,6 +136,15 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
slope.get("rows") or [],
settings.get("topsoil_thickness_m"),
{type_id: definition.name for type_id, definition in structure_type_map().items()},
# 부대시설 개소 — 산식으로 만들지 않고 **설계자가 넣은 값**만 쓴다(확정 ⑬).
settings.get("ancillary_counts") or {},
# 표토 운반거리 — 별표2 가 요구하는 운반·적치의 밑수(거리는 현장값).
settings.get("topsoil_haul_distance_m"),
# 임목축적 등급 — 품셈 9-21 제근이 소·중·밀로 갈리는 축(본수가 아니다).
settings.get("stand_volume_class"),
# 임목파쇄 — 기본 꺼짐. 켠 프로젝트에서만 줄이 선다(확정 5차 5번).
settings.get("wood_chipping_enabled"),
settings.get("wood_chipping_volume_m3"),
)
method, method_is_default = concrete_placing_method(settings)
# ⚠ 금액에 바로 걸리는 값이라 「기본값으로 돌고 있음」을 응답에 실어 화면이 띄우게 한다.
@@ -148,6 +162,175 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
return JSONResponse(content=table)
#: 갈래 칸 ↔ 다짐 환산계수 `C`. 정의처는 `config_system_design` 한 곳뿐이다.
_COMPACTED_FACTOR = {
"ea_m3": float(EARTHWORK_CONVERSION_FACTORS["soil"]["compacted"]),
"rr_m3": float(EARTHWORK_CONVERSION_FACTORS["ripping_rock"]["compacted"]),
"br_m3": float(EARTHWORK_CONVERSION_FACTORS["blasting_rock"]["compacted"]),
}
def _spoil_sites(designs: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""배치된 사토장(유용토운반작업장) — 측점 설계에 실려 온 구간값을 모은다.
여기서 **다시 세지 않는다** 용량·담긴 양은 B06 정한 값이고, 함수는 그것을
구조물 단위로 접어 어디에 얼마나 담기나 만든다.
"""
sites: dict[str, dict[str, Any]] = {}
for row in designs:
design = (row or {}).get("design") or {}
key = str(design.get("spoil_fill_structure_id") or "")
if not key or not float(design.get("spoil_fill_area_m2") or 0.0) > 0:
continue
chainage = float(row.get("chainage_m") or 0.0)
site = sites.setdefault(
key,
{
"structure_id": key,
"from_m": chainage,
"to_m": chainage,
"capacity_m3": float(design.get("spoil_fill_capacity_m3") or 0.0),
"placed_m3": float(design.get("spoil_fill_placed_m3") or 0.0),
"unplaced_m3": float(design.get("spoil_fill_unplaced_m3") or 0.0),
"extra_distance_m": design.get("spoil_fill_extra_distance_m"),
},
)
site["from_m"] = min(site["from_m"], chainage)
site["to_m"] = max(site["to_m"], chainage)
for site in sites.values():
site["center_m"] = (site["from_m"] + site["to_m"]) / 2
return sorted(sites.values(), key=lambda item: item["center_m"])
def _site_distance_m(
sites: list[dict[str, Any]], residuals: list[dict[str, Any]]
) -> tuple[float | None, str]:
"""사토장까지의 **가중평균 운반거리**(m)와 근거 문구.
발생점 사토장 측점 누가거리다(2026-09-09 사용자 확정 사토장이 측점 위에만
서므로 가정할 것이 없다). 사토가 여러 자리에 남으면 물량으로 가중평균한다.
사토장이 없으면 `None` 설계 입력(`spoil_site_distance_m`)으로 되돌아간다.
**임의 거리를 넣지 않는다**(그대로 금액이 된다).
"""
if not sites:
return None, ""
work = 0.0
volume = 0.0
for residual in residuals:
if str(residual.get("kind") or "") != "spoil":
continue
amount = float(residual.get("volume_m3") or 0.0) - float(residual.get("natural_m3") or 0.0)
if amount <= 0:
continue
center = (float(residual.get("from_m") or 0.0) + float(residual.get("to_m") or 0.0)) / 2
nearest = min(sites, key=lambda site: abs(site["center_m"] - center))
extra = nearest.get("extra_distance_m")
distance = abs(nearest["center_m"] - center) + float(extra or 0.0)
work += amount * distance
volume += amount
if volume <= 0:
return None, ""
where = " · ".join(f"{site['center_m']:,.1f}m" for site in sites)
return work / volume, f"사토장 측점({where})까지 발생점 기준 가중평균"
def _spoil_of(
plan: dict[str, Any] | None,
settings: dict[str, Any],
sites: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""사토 — 실어 낼 물량과 거리. 유토곡선 결과에서 **다시 세지 않고 그대로** 가져온다.
`spoil_m3` **공제·가산이 끝난 **이다(채집석 공제는 빼고 구조물 잔토는 더한 ).
여기서 만지면 셈이 된다.
자연방토(`natural_spoil_m3`) 실어 내지 않는 몫이라 **뺀다**.
**상태가 갈린다.** 유토곡선 잔량은 **다짐상태**이고 품셈 운반(10-11·10-12) 밑수는
**자연상태**(식이 `f = 1/L` 스스로 곱한다). 잔량이 되돌린
(`natural_m3_by_ground`) 들고 오면 **그것을 쓰고**, 없으면 다짐값으로 서되 사실을
근거에 적는다 조용히 쓰면 상태가 어긋난 물량이 단가에 물린다(2026-09-09 확인).
"""
haul_plan = (plan or {}).get("haul_plan") if isinstance(plan, dict) else None
source = haul_plan if isinstance(haul_plan, dict) else (plan or {})
total = float(source.get("spoil_m3") or 0.0)
natural = float(source.get("natural_spoil_m3") or 0.0)
volume = max(total - natural, 0.0)
# 지반 갈래 — 사토 잔량이 갈래별 물량을 들고 온다(2026-09-08 랩탑 메인). 갈래를 못 붙인
# 몫은 `ground_unknown_m3` 로 따로 온다. **여기서 안분하지 않는다** — 근거 없는 몫을
# 토사로 눅이면 덤프 단가가 임의로 정해진다.
grounds: dict[str, float] = {}
unknown = 0.0
# 잔량마다 **사토장까지 거리**가 실려 올 수 있다(2026-09-09 사용자 확정 — 사토장은 이미
# 있는 측점 위에만 놓이므로 「발생점 → 사토장 측점」 누가거리로 그냥 나온다).
# 갈래별 **가중평균**을 낸다 — 실무 내역이 (운반수단 × 지반)별 평균 하나를 올린다.
work: dict[str, float] = {}
metered: dict[str, float] = {}
for residual in source.get("residuals") or []:
if str(residual.get("kind") or "") != "spoil":
continue
leg_distance = residual.get("spoil_haul_distance_m")
# ⚠ 잔량은 **다짐상태**로만 읽는다 — 되돌리는 자리는 아래 한 곳뿐이다.
# 두 곳에서 되돌리면 ÷C 가 두 번 걸린다.
for key in ("ea_m3", "rr_m3", "br_m3"):
value = float(residual.get(key) or 0.0)
if value > 0:
grounds[key] = grounds.get(key, 0.0) + value
if isinstance(leg_distance, (int, float)) and float(leg_distance) > 0:
work[key] = work.get(key, 0.0) + value * float(leg_distance)
metered[key] = metered.get(key, 0.0) + value
unknown += float(residual.get("ground_unknown_m3") or 0.0)
note_parts = [f"사토 {total:,.2f}"]
if natural > 0:
note_parts.append(f"자연방토 {natural:,.2f}㎥ 뺀 값")
added = source.get("structure_spoil_added_m3")
if added:
note_parts.append(f"구조물 잔토 {float(added):,.2f}㎥ 얹힌 뒤")
deducted = source.get("collected_stone_deducted_m3")
if deducted:
note_parts.append(f"채집석 {float(deducted):,.2f}㎥ 빠진 뒤")
# ⚠ **상태를 값으로 낸다**(2026-09-09) — 잔량은 유토곡선이 쌓은 **다짐상태**이고,
# 내역서에 오르는 수량은 **자연상태**다(`config_system_design` 5-4-3 「운반거리 산정 시
# 모든 수량은 다짐상태로 환산해 계산하고, **내역서에 적용하는 수량은 자연상태로 한다**」).
# 여기서 ÷C 한 값을 함께 내 받는 쪽이 **또 환산하지 않게** 한다.
# ⚠ 갈래를 못 붙인 몫은 계수가 없어 **환산하지 않는다** — 토사 계수로 눅이면 근거 없이
# 금액이 움직인다. 그 사실을 사유로 낸다.
natural_by_ground = {
key: round(value / _COMPACTED_FACTOR[key], 3)
for key, value in grounds.items()
if key in _COMPACTED_FACTOR
}
if unknown > 0:
note_parts.append(f"⚠ 갈래를 못 붙인 {unknown:,.2f}㎥ 는 상태도 못 되돌림")
# 거리 — 사토장이 서 있으면 **그 측점까지의 누가거리**로 나온다. 없으면 설계 입력값.
site_distance, site_basis = _site_distance_m(sites or [], source.get("residuals") or [])
if site_distance is not None:
note_parts.append(site_basis)
placed = sum(float(site.get("placed_m3") or 0.0) for site in sites or [])
unplaced = sum(float(site.get("unplaced_m3") or 0.0) for site in sites or [])
note_parts.append(f"사토장 수용 {placed:,.1f}")
if unplaced > 0:
note_parts.append(f"⚠ 못 담는 {unplaced:,.1f}㎥ 는 밖으로 내야 함")
return {
"volume_m3": round(volume, 3),
"volume_basis": "compacted",
"distance_m": (
round(site_distance, 3)
if site_distance is not None
else settings.get("spoil_site_distance_m")
),
"distance_basis": ("사토장(측점) 기준" if site_distance is not None else "설계 입력값"),
"sites": sites or [],
"note": " · ".join(note_parts),
"by_ground_m3": {key: round(value, 3) for key, value in grounds.items()},
"natural_m3_by_ground": natural_by_ground,
"natural_volume_basis": "natural",
"ground_unknown_m3": round(unknown, 3),
# 갈래별 가중평균 거리 — 사토장이 놓였을 때만 찬다. 비면 설정 거리로 떨어진다.
"distance_by_ground_m": {
key: round(work[key] / metered[key], 2) for key in work if metered.get(key)
},
}
async def _route_structures(project_id: UUID) -> list[dict[str, Any]]:
"""배치된 구조물 목록 — 사방 시설이 있는지 보려는 것뿐이다. 없으면 빈 목록."""
try:
@@ -211,11 +394,39 @@ class QuantitySettingsBody(BaseModel):
concrete_placing_method: str | None = None
# 표토제거 두께(m). 품셈이 정하는 값이 아니라 설계 입력이다(9-15 [주]②).
topsoil_thickness_m: float | None = None
# 부대시설 개소 — `{항목키: 개소}`(2026-09-09 확정 ⑬).
# ⚠ 산식(연장÷500)으로 만들지 않는다 — 임도규정이 「필요시 거리를 조정」이라 하고
# 기점 포함·갈림길 중복을 원문이 정하지 않는다. **설계자가 넣는 값**이다.
ancillary_counts: dict[str, float] | None = None
# 층따기 길이(깊이, m). 면적 × 이 값 = ㎥ (확정 2차 ①).
bench_cut_depth_m: float | None = None
# 사토장까지 운반거리(m). 유토곡선이 낸 사토를 **실어 내는 줄**이 이 값으로 선다.
spoil_site_distance_m: float | None = None
# 기초잡석 두께(m) — 확정 3차 ② 0.2. 폭은 버림 폭과 같다(KCS 34 50 05).
rubble_base_thickness_m: float | None = None
# 구조물터파기 용수 유무 — "육상"·"용수". ⚠ 기본 육상은 **통상값**이지 사용자 확정이 아니다.
structure_trench_water: str | None = None
# 표토 운반거리(m) — 별표2 가 요구하는 운반·적치의 밑수. 비면 그 줄이 막힌다.
topsoil_haul_distance_m: float | None = None
# 임목축적 등급 — "소림"·"중림"·"밀림"(품셈 9-21 [주]①). `""` 는 「안 정함」이다.
stand_volume_class: str | None = None
# 규준틀 개소당 재료 — `{자재명: 수량}`. 비우면 제안값(실무 관측)이 선다.
frame_material: dict[str, Any] | None = None
# 임목파쇄 — 기본 꺼짐(확정 5차 5번). 켜면 줄이 서고, 부피를 넣으면 값이 선다.
wood_chipping_enabled: bool | None = None
wood_chipping_volume_m3: float | None = None
#: `None` 이 「안 정함」을 뜻하는 칸 — 저장에서 **버리지 않고 그대로 덮어쓴다**.
#: 빈 문자열로 되돌리는 칸(시공법·타설 방식)과 달리 숫자 칸은 되돌릴 값이 `None` 뿐이다.
NULLABLE_SETTING_KEYS = ("topsoil_thickness_m",)
NULLABLE_SETTING_KEYS = (
"topsoil_thickness_m",
"bench_cut_depth_m",
"spoil_site_distance_m",
"rubble_base_thickness_m",
"topsoil_haul_distance_m",
"wood_chipping_volume_m3",
)
@router.put("/{project_id}/quantity/settings")
@@ -260,7 +471,8 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
_save_quantity,
root,
values,
("rock_methods", "material_supply", "concrete_placing_method") + NULLABLE_SETTING_KEYS,
("rock_methods", "material_supply", "concrete_placing_method", "ancillary_counts")
+ NULLABLE_SETTING_KEYS,
)
except Exception:
logger.exception("B08 설정 저장 실패(쓰기): project_id=%s", project_id)
+111 -3
View File
@@ -25,17 +25,25 @@ from fastapi import APIRouter
from fastapi.responses import JSONResponse
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B06_Section.B06_Section_Repository import get_cross_section_designs
from B06_Section.B06_Section_Repository import get_workflow_route_context
from B05_Profile.B05_Profile_Structures_Repository import load_structures
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff, load_mapping, summarize
from B08_Quantity.B08_Quantity_Engine_MaterialSummary import build_table as build_material_table
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import (
ground_types_from_designs,
section_modes_from_designs,
)
from common_util.common_util_project_settings import (
concrete_placing_method,
quantity_settings,
rock_classes,
rock_method,
)
from B08_Quantity.B08_Quantity_Engine_HaulInputs import haul_inputs
from B08_Quantity.B08_Quantity_Engine_Preparation import frame_material_rows
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_structure_lengths import structure_lengths
from config.config_db import run_with_connection
@@ -78,6 +86,42 @@ def _collect_structures(
return targets, names, sorted(set(skipped))
async def _ground_types(project_id: UUID) -> dict[float, str]:
"""측점별 지반 갈래(`soil`·`ripping_rock`·`blasting_rock`).
구조물터파기(품셈 9-13) **토질 ** 값으로 갈린다. 단면유형과 같은 자리에서
오므로 읽는 방식도 같다 읽으면 표로 두고 판정이 가름 되게 한다.
"""
try:
context = await run_with_connection(get_workflow_route_context, project_id)
route_id = int((context or {}).get("route_id") or 0)
if not route_id:
return {}
designs = await run_with_connection(get_cross_section_designs, route_id)
except Exception:
logger.exception("B08 지반 갈래 조회 실패: project_id=%s", project_id)
return {}
return ground_types_from_designs(designs)
async def _section_modes(project_id: UUID) -> dict[float, str]:
"""측점별 단면유형(`left_cut` 등). 구조물이 **성토면인가 절토면인가**를 가릴 때 쓴다.
저장 키를 만들지 않는다 이미 저장되는 `design.section_mode` 읽기만 한다.
읽으면 표로 두고, 판정이 가를 근거 없음 되게 한다(성토로 눅이지 않음).
"""
try:
context = await run_with_connection(get_workflow_route_context, project_id)
route_id = int((context or {}).get("route_id") or 0)
if not route_id:
return {}
designs = await run_with_connection(get_cross_section_designs, route_id)
except Exception:
logger.exception("B08 단면유형 조회 실패: project_id=%s", project_id)
return {}
return section_modes_from_designs(designs)
@router.get("/{project_id}/quantity/material-summary")
async def get_material_summary(project_id: UUID) -> JSONResponse:
"""구조물 원단위와 자재총괄을 **한 응답**으로 낸다.
@@ -103,11 +147,19 @@ async def get_material_summary(project_id: UUID) -> JSONResponse:
content={"status": "error", "message": "구조물 정본을 읽지 못했습니다."},
)
unit_table = build_unit_table(structures, names)
settings = quantity_settings(project_root)
unit_table = build_unit_table(
structures,
names,
await _section_modes(project_id),
await _ground_types(project_id),
settings.get("rubble_base_thickness_m"),
)
material_table = build_material_table(
unit_table,
supply_map=settings.get("material_supply") or {},
# 콘크리트 할증은 **레미콘일 때만** 붙는다 — 방식이 이름을 가른다(확정 3차 ⑥).
concrete_placing_method=settings.get("concrete_placing_method"),
)
# 묶음으로 서는 구조물의 조각을 화면에도 보인다 — 코드만으로는 사람이 검증 못 한다.
handoff = build_handoff(unit_quantity_table=unit_table)
@@ -132,6 +184,36 @@ async def get_material_summary(project_id: UUID) -> JSONResponse:
)
async def project_haul_inputs(project_id: UUID) -> dict[str, Any]:
"""유토곡선(B06)이 받아야 할 **구조물 몫** — 채집석 공제 · 구조물 잔토.
**B06 함수를 부르면 된다.** B08 전개에서 나오는 것이라 저쪽이 다시
세면 같은 계산이 벌이 된다(CLAUDE.md 5). 값은 **양수 ** 이고 빼고 더하는 것은
받는 몫이다. 읽으면 (`None`) 0 으로 눅이지 않는다.
"""
try:
stored_path = await run_with_connection(get_project_storage_relative_path, project_id)
project_root = resolve_stored_project_path(stored_path)
structures, names, _skipped = _collect_structures(project_root)
unit_table = build_unit_table(
structures,
names,
await _section_modes(project_id),
await _ground_types(project_id),
quantity_settings(project_root).get("rubble_base_thickness_m"),
)
except Exception:
logger.exception("B08 유토곡선 입력 조회 실패: project_id=%s", project_id)
return haul_inputs(None)
return haul_inputs(unit_table)
@router.get("/{project_id}/quantity/haul-inputs")
async def get_haul_inputs(project_id: UUID) -> JSONResponse:
"""같은 값을 화면·다른 창이 볼 수 있게 낸 자리. 계산은 위 함수 한 벌이다."""
return JSONResponse(content=await project_haul_inputs(project_id))
@router.get("/{project_id}/quantity/handoff")
async def get_handoff(project_id: UUID) -> JSONResponse:
"""B09 로 넘길 두 벌 — 작업 공종 축과 자재 축 (일감 9).
@@ -153,15 +235,37 @@ async def get_handoff(project_id: UUID) -> JSONResponse:
)
structures, names, skipped = _collect_structures(project_root)
unit_table = build_unit_table(structures, names)
settings = quantity_settings(project_root)
unit_table = build_unit_table(
structures,
names,
await _section_modes(project_id),
await _ground_types(project_id),
settings.get("rubble_base_thickness_m"),
)
material_table = build_material_table(
unit_table, supply_map=settings.get("material_supply") or {}
unit_table,
supply_map=settings.get("material_supply") or {},
concrete_placing_method=settings.get("concrete_placing_method"),
)
# 토공·운반 표는 토적표 라우터의 것을 그대로 쓴다 — 여기서 다시 만들지 않는다.
earthwork = await _earthwork_tables(project_id)
# 규준틀 재료 — **개소가 선 뒤에야 설 수 있어** 준비공 표를 받은 다음 자재 축에 얹는다.
# ⚠ 값은 **제안값(실무 관측)**이고 산출 조건에서 고칠 수 있다 — 그 사실이 줄 사유에 적힌다.
frame_rows = [
row
for row in ((earthwork.get("preparation") or {}).get("rows") or [])
if str(row.get("item") or "").endswith("규준틀")
]
material_table = build_material_table(
unit_table,
supply_map=settings.get("material_supply") or {},
concrete_placing_method=settings.get("concrete_placing_method"),
extra_materials=frame_material_rows(frame_rows, settings.get("frame_material") or {}),
)
handoff = build_handoff(
summary_table=earthwork.get("summary"),
haul_table=earthwork.get("haul"),
@@ -182,6 +286,10 @@ async def get_handoff(project_id: UUID) -> JSONResponse:
ground_methods={name: rock_method(settings, name) for name in rock_classes(settings)},
# 타설 방식 — 안 정했으면 기본값으로 서되 그 사실을 `placing_notes` 가 알린다.
concrete_placing_method=concrete_placing_method(settings)[0],
# 층따기 길이 — 면적 × 이 값으로 ㎥ 를 낸다(확정 2차 ①). 안 넣었으면 막히고 사유가 감.
bench_cut_depth_m=settings.get("bench_cut_depth_m"),
# 용수 유무 — 기본 「육상」은 **통상값**이다(확정 3차 ④). 사유·화면에 그 사실이 뜬다.
structure_trench_water=settings.get("structure_trench_water"),
)
handoff["summary"] = summarize(handoff)
handoff["skipped_structures"] = skipped
@@ -67,6 +67,26 @@ export interface QuantitySettings {
concrete_placing_method?: string | null;
/** 표토 두께(m). `null`·없음이면 **안 정한 것**이라 표토제거 줄이 「근거 없음」으로 선다. */
topsoil_thickness_m?: number | null;
/** 층따기 길이(m) — 면적 × 이 값 = ㎥ (확정 2차 ①). 비면 층따기 줄이 막힌다. */
bench_cut_depth_m?: number | null;
/** 기초잡석 두께(m) — 확정 3차 ② 0.2. 폭은 버림 폭과 같다(KCS 34 50 05). */
rubble_base_thickness_m?: number | null;
/** 사토장까지 거리(m) — 현장값. 비면 사토 운반 줄이 막힌다. */
spoil_site_distance_m?: number | null;
/** 구조물터파기 용수 — `"육상"`·`"용수"`. ⚠ 「육상」은 통상값이지 사용자 확정이 아니다. */
structure_trench_water?: string | null;
/** 표토 운반거리(m) — 별표2 가 요구하는 운반·적치의 밑수. 비면 그 줄이 막힌다. */
topsoil_haul_distance_m?: number | null;
/** 부대시설 개소 — `{항목키: 개소}`. ⚠ 산식으로 만들지 않는다(확정 13). */
ancillary_counts?: Record<string, number | null>;
/** 임목축적 등급 — `"소림"`·`"중림"`·`"밀림"`(품셈 9-21 [주]①). 본수가 아니라 축적이다. */
stand_volume_class?: string | null;
/** 규준틀 개소당 재료 — `{자재명: 수량}`. 비우면 제안값(실무 관측)이 선다. */
frame_material?: Record<string, number | null>;
/** 임목파쇄 — 기본 꺼짐(확정 5차 5번). 켜야 줄이 선다. */
wood_chipping_enabled?: boolean | null;
/** 파쇄 부피(㎥) — 켜도 이 값이 없으면 줄만 서고 사유가 남는다. */
wood_chipping_volume_m3?: number | null;
}
export interface EarthworkTable {
@@ -184,6 +184,8 @@ const CSS = `
.b08-quantity__message { margin: 0; padding: 16px; font-size: 13px; color: var(--color-text-secondary); }
.b08-quantity__field { display: flex; justify-content: space-between; gap: 8px; font-size: 12px; padding: 2px 0; }
.b08-quantity__field-value { color: var(--color-text-secondary); font-variant-numeric: tabular-nums; }
/* 칸 밑 근거 한 줄 — 왜 그 값인지 화면에서 보이게 한다(2026-09-09 사용자 지시). */
.b08-quantity__hint { margin: 0 0 6px; font-size: 11px; line-height: 1.4; color: var(--color-text-secondary); }
`;
/** 스타일을 한 번만 넣는다 — 페이지를 다시 그려도 중복되지 않는다. */
+227
View File
@@ -86,6 +86,18 @@ async function saveQuantitySettings(projectId: string, draft: DraftSettings): Pr
concrete_placing_method: draft.concrete_placing_method,
// ⚠ `null` 도 그대로 보낸다 — 「안 정함」으로 되돌릴 길이 있어야 한다(시공법과 같은 규칙).
topsoil_thickness_m: draft.topsoil_thickness_m,
// 확정 2차 ① · 3차 ②③④ — 값이 화면 칸에서 오고, 비면 그 줄이 막힌다.
bench_cut_depth_m: draft.bench_cut_depth_m,
rubble_base_thickness_m: draft.rubble_base_thickness_m,
spoil_site_distance_m: draft.spoil_site_distance_m,
structure_trench_water: draft.structure_trench_water,
topsoil_haul_distance_m: draft.topsoil_haul_distance_m,
stand_volume_class: draft.stand_volume_class,
frame_material: draft.frame_material,
wood_chipping_enabled: draft.wood_chipping_enabled,
wood_chipping_volume_m3: draft.wood_chipping_volume_m3,
// 개소는 **통째로** 보낸다 — 지운 항목까지 그대로 가야 되돌릴 길이 있다.
ancillary_counts: draft.ancillary_counts,
}),
},
);
@@ -105,6 +117,36 @@ function field(label: string, value: string): HTMLElement {
return row;
}
/** 규준틀 재료 칸 — 이름은 **서버 `FRAME_MATERIAL_SUGGESTED` 와 같은 낱말**이라야 한다. */
const FRAME_MATERIAL_FIELDS: ReadonlyArray<readonly [string, string, string]> = [
["각재 50×50", "B08_Quantity_Frame_Square", "0.0001"],
["판재 T12", "B08_Quantity_Frame_Board", "0.0001"],
["못", "B08_Quantity_Frame_Nail", "0.01"],
];
/** 채워 보이는 **제안값**(실무 관측). ⚠ 법정 기준이 아니라 「고치라고 보이는 값」이다. */
const FRAME_MATERIAL_SUGGESTED: Record<string, number> = {
"각재 50×50": 0.0044,
"판재 T12": 0.0029,
: 0.03,
};
/** 부대시설 항목 키 — 서버 `ANCILLARY_ITEMS` 와 **같은 차례·같은 낱말**이라야 한다. */
const ANCILLARY_KEYS = [
"national_point_sign",
"guide_sign",
"gate",
"site_container",
"flood_supplies",
] as const;
/** 칸 밑에 붙는 **근거 한 줄** — 왜 그 값인지 화면에서 보이게 한다(2026-09-09 사용자 지시). */
function hintRow(text: string): HTMLElement {
const row = document.createElement("p");
row.className = "b08-quantity__hint";
row.textContent = text;
return row;
}
/** 반영률 키 → 사람이 읽는 이름. 서버 키를 그대로 보이면 설계자가 못 읽는다. */
const RATIO_LABEL_KEYS: Record<string, keyof typeof ui_locales> = {
fill_slope_compaction: "B08_Quantity_Ratio_FillCompaction",
@@ -228,6 +270,25 @@ interface DraftSettings {
concrete_placing_method: string;
// 표토 두께(m) — `null` 은 「안 정함」. 정해야 표토제거 줄이 선다(품셈 9-15 [주]② 의 T).
topsoil_thickness_m: number | null;
// 층따기 길이(m) — 면적 × 이 값 = ㎥ (확정 2차 ①). `null` 이면 층따기 줄이 막힌다.
bench_cut_depth_m: number | null;
// 기초잡석 두께(m) — 확정 3차 ② 0.2. 폭은 버림 폭과 같다(KCS 34 50 05).
rubble_base_thickness_m: number | null;
// 사토장까지 거리(m) — 현장값. `null` 이면 사토 운반 줄이 막힌다(확정 3차 ③ 는 미정).
spoil_site_distance_m: number | null;
// 구조물터파기 용수 — "육상"·"용수". ⚠ 「육상」은 **통상값**이지 사용자 확정이 아니다.
structure_trench_water: string;
// 표토 운반거리(m) — 별표2 가 요구하는 운반·적치의 밑수. `null` 은 「안 정함」.
topsoil_haul_distance_m: number | null;
// 부대시설 개소 — `{항목키: 개소}`. ⚠ 산식으로 만들지 않는다(확정 13).
ancillary_counts: Record<string, number | null>;
// 임목축적 등급 — "소림"·"중림"·"밀림". ⚠ 본수가 아니라 축적이다(품셈 9-21 [주]①).
stand_volume_class: string;
// 규준틀 개소당 재료 — 비우면 **제안값(실무 관측)**이 선다. 값이 아니라 「고칠 수 있음」이 요점.
frame_material: Record<string, number | null>;
// 임목파쇄 — **기본 꺼짐**(확정 5차 5번). 켜야 줄이 선다. 근주이식은 칸 자체가 없다.
wood_chipping_enabled: boolean;
wood_chipping_volume_m3: number | null;
// 자재별 관급/사급 — 표 안에서 줄마다 고른 값.
material_supply: Record<string, SupplyChoice>;
dirty: boolean;
@@ -408,6 +469,160 @@ function buildQuantitySidePanel(
),
);
// ── 구조물·사토 — 확정 2차 ① · 3차 ②③④ 의 값들 ──────────────────────
// ⚠ 사용자 지시(2026-09-09): **값을 코드에 박고 끝내지 말고 화면에 칸으로 세우고
// 지금 값과 근거를 보이고 바꿀 수 있게 할 것.** 정한 값이 화면에 안 보이면 다음 사람이
// 왜 그 값인지 모른다.
panel.append(field(L("B08_Quantity_Side_Structure"), ""));
panel.append(
optionalNumberField(
L("B08_Quantity_Side_BenchCut_Label"),
draft.bench_cut_depth_m,
"0.01",
(value) => {
draft.bench_cut_depth_m = value;
draft.dirty = true;
},
),
);
panel.append(hintRow(L("B08_Quantity_Side_BenchCut_Hint")));
panel.append(
optionalNumberField(
L("B08_Quantity_Side_Rubble_Label"),
draft.rubble_base_thickness_m,
"0.05",
(value) => {
draft.rubble_base_thickness_m = value;
draft.dirty = true;
},
),
);
panel.append(hintRow(L("B08_Quantity_Side_Rubble_Hint")));
panel.append(
optionalNumberField(
L("B08_Quantity_Side_SpoilDistance_Label"),
draft.spoil_site_distance_m,
"10",
(value) => {
draft.spoil_site_distance_m = value;
draft.dirty = true;
},
),
);
panel.append(hintRow(L("B08_Quantity_Side_SpoilDistance_Hint")));
panel.append(
selectField(
L("B08_Quantity_Side_Water_Label"),
draft.structure_trench_water,
[
{ value: "", label: L("B08_Quantity_Water_Unset") },
{ value: "육상", label: L("B08_Quantity_Water_Dry") },
{ value: "용수", label: L("B08_Quantity_Water_Wet") },
],
(value) => {
draft.structure_trench_water = value;
draft.dirty = true;
},
),
);
panel.append(hintRow(L("B08_Quantity_Side_Water_Hint")));
panel.append(
optionalNumberField(
L("B08_Quantity_Side_TopsoilHaul_Label"),
draft.topsoil_haul_distance_m,
"10",
(value) => {
draft.topsoil_haul_distance_m = value;
draft.dirty = true;
},
),
);
panel.append(hintRow(L("B08_Quantity_Side_TopsoilHaul_Hint")));
panel.append(
selectField(
L("B08_Quantity_Side_StandVolume_Label"),
draft.stand_volume_class,
[
{ value: "", label: L("B08_Quantity_StandVolume_Unset") },
{ value: "소림", label: L("B08_Quantity_StandVolume_Low") },
{ value: "중림", label: L("B08_Quantity_StandVolume_Mid") },
{ value: "밀림", label: L("B08_Quantity_StandVolume_High") },
],
(value) => {
draft.stand_volume_class = value;
draft.dirty = true;
},
),
);
panel.append(hintRow(L("B08_Quantity_Side_StandVolume_Hint")));
// ── 부대시설 개소 — ⚠ **산식으로 만들지 않는다**(확정 13). 넣어야 줄이 선다 ──
panel.append(field(L("B08_Quantity_Side_Ancillary"), ""));
for (const key of ANCILLARY_KEYS) {
panel.append(
optionalNumberField(
L(`B08_Quantity_Ancillary_${key}` as keyof typeof ui_locales),
draft.ancillary_counts[key] ?? null,
"1",
(value) => {
draft.ancillary_counts[key] = value;
draft.dirty = true;
},
),
);
}
panel.append(hintRow(L("B08_Quantity_Side_Ancillary_Hint")));
// ── 규준틀 재료 — ⚠ **세는 것은 확정이고 수량만 모르던 자리**(품셈 [주]④ 「설계수량에 따른다」).
// 그래서 **제안값을 채워 보이고 고칠 수 있게** 둔다(확정 ⑨·⑩ 과 같은 틀).
// ⚠ 값만 박고 근거를 안 보이면 사용자 지시(「대신 페이지에 남길 것」)를 어기는 것이라
// 칸 밑에 **관측값임**과 **손율 원문값**을 함께 적는다.
panel.append(field(L("B08_Quantity_Side_Frame"), ""));
for (const [key, label, step] of FRAME_MATERIAL_FIELDS) {
panel.append(
optionalNumberField(
L(label as keyof typeof ui_locales),
draft.frame_material[key] ?? FRAME_MATERIAL_SUGGESTED[key],
step,
(value) => {
draft.frame_material[key] = value;
draft.dirty = true;
},
),
);
}
panel.append(hintRow(L("B08_Quantity_Side_Frame_Hint")));
// ── 임목파쇄 — ⚠ **기본 꺼짐**(확정 5차 5번). 「셀지 말지가 설계 판단」이라 켜야 줄이 선다.
// ⚠ 근주이식은 **칸 자체를 안 만든다** — 켤 자리가 없으면 물을 일도 없다.
panel.append(field(L("B08_Quantity_Side_Chipping"), ""));
panel.append(
selectField(
L("B08_Quantity_Chipping_Label"),
draft.wood_chipping_enabled ? "on" : "",
[
{ value: "", label: L("B08_Quantity_Chipping_Off") },
{ value: "on", label: L("B08_Quantity_Chipping_On") },
],
(value) => {
draft.wood_chipping_enabled = value === "on";
draft.dirty = true;
},
),
);
panel.append(
optionalNumberField(
L("B08_Quantity_Chipping_Volume"),
draft.wood_chipping_volume_m3,
"1",
(value) => {
draft.wood_chipping_volume_m3 = value;
draft.dirty = true;
},
),
);
panel.append(hintRow(L("B08_Quantity_Side_Chipping_Hint")));
// ── 콘크리트 타설 방식 — ⚠ **금액에 바로 걸리는 값**이라 잠정임을 조용히 두지 않는다 ──
panel.append(field(L("B08_Quantity_Side_Placing"), ""));
panel.append(
@@ -659,6 +874,18 @@ export async function renderB08Quantity(root: HTMLElement): Promise<void> {
rock_methods: { ...((stored.rock_methods ?? {}) as Record<string, string>) },
concrete_placing_method: (stored.concrete_placing_method as string) ?? "",
topsoil_thickness_m: (stored.topsoil_thickness_m as number | null) ?? null,
bench_cut_depth_m: (stored.bench_cut_depth_m as number | null) ?? null,
rubble_base_thickness_m: (stored.rubble_base_thickness_m as number | null) ?? null,
spoil_site_distance_m: (stored.spoil_site_distance_m as number | null) ?? null,
structure_trench_water: (stored.structure_trench_water as string) ?? "",
topsoil_haul_distance_m: (stored.topsoil_haul_distance_m as number | null) ?? null,
stand_volume_class: (stored.stand_volume_class as string) ?? "",
frame_material: { ...((stored.frame_material ?? {}) as Record<string, number | null>) },
wood_chipping_enabled: Boolean(stored.wood_chipping_enabled),
wood_chipping_volume_m3: (stored.wood_chipping_volume_m3 as number | null) ?? null,
ancillary_counts: {
...((stored.ancillary_counts ?? {}) as Record<string, number | null>),
},
material_supply: { ...((stored.material_supply ?? {}) as Record<string, SupplyChoice>) },
dirty: false,
};
+3 -1
View File
@@ -44,7 +44,9 @@ OPTION_LABELS = {
"form": ("옹벽 형식", "구조물 상세 입력"),
"height_m": ("높이(m)", "구조물 배치"),
"length_m": ("연장(m)", "구조물 배치"),
"face_slope_ratio": ("전면 기울기", "아직 입력 칸이 없음"),
# 2026-09-09 칸이 생겼음(`ebdf2988`) — 「아직 칸이 없음」이 거짓이 되어 고침.
# ⚠ 비워 두면 품셈 표준경사표로 자동 판정된다(확정 ⑨).
"face_slope_ratio": ("전면 기울기", "구조물 상세 입력 — 비우면 품셈 표준경사로 자동"),
}
@@ -155,6 +155,14 @@ def _leaf_row(
if item.application_ratio_pct is not None:
# ⚠ 곱하지 않는다 — B08 이 이미 곱한 값이다. 산출근거로만 적는다.
row.note = f"반영률 {item.application_ratio_pct}% 적용 후 수량"
elif item.application_ratio_breakdown:
# ⚠ **「율 없음」이 아니라 「갈래마다 다름」이다.** 율이 갈리는 줄은 B08 이 `pct` 를
# 비우고 갈래로만 보낸다. 그 사실을 안 적으면 **값은 맞는데 왜 그 수량인지**를
# 사람이 못 본다 — 값이 맞아도 그것은 반쪽이다(2026-09-09 두 창 확인).
parts = ", ".join(
f"{name} {value}%" for name, value in item.application_ratio_breakdown.items()
)
row.note = f"반영률이 갈래마다 다릅니다 — {parts} (적용 후 수량)"
if not item.in_bill:
# 검산용 줄 — 수량은 보이되 **단가를 안 붙인다**(PLAN 8-7 ㉡ 와 같은 성격).
@@ -163,7 +171,15 @@ def _leaf_row(
result.excluded.append(row)
return row
if item.blocked_reason:
# ⚠⚠ **차단인지 아닌지는 `blocked_kind` 가 정한다 — 문구가 아니다.**
# 2026-09-09 실측: 배수관 다섯 줄이 `blocked_kind=None · in_bill=True` 인데도
# **사유가 있다는 것만으로 막혀** 금액이 안 서고 있었다. 그 사유는 차단이 아니라
# **주의 문구**였다 — 「관종을 안 정해 기본값(파형강관)으로 섰습니다」.
# ⇒ 사유만 온 줄은 **금액을 세우고 그 문구를 곁말로** 단다.
if item.blocked_reason and not item.blocked_kind:
row.note = " / ".join(part for part in (row.note, f"{item.blocked_reason}") if part)
if item.blocked_reason and item.blocked_kind:
# B08 이 「왜 못 골랐는지」를 적어 보냈다 — **그 문구를 그대로** 보인다.
# 사용자가 입력하면 풀리는 것(`input_missing`)과 우리가 만들어야 하는 것을
# 가르지 않으면, 사용자가 「후보를 고르면 되나」로 잘못 읽는다.
@@ -206,6 +222,10 @@ def _leaf_row(
if children:
names = ", ".join(f"{c[2:]} {unit_prices.book.title(c).name}" for c in children)
row.note = f"이 공종엔 일위대가가 없고 한 층 아래에 있습니다 — 후보: {names}"
# 관경이 표 밖이면 **무엇을 정해야 하는지**까지 가리킨다.
diameter_note = pipe_diameter_note(node.code, item.variant_value)
if diameter_note:
row.note = f"{row.note} / {diameter_note}"
reason = f"일위대가가 하위 공종에 있음(후보 {len(children)}건)"
else:
# ⚠ 「아직 안 만든 것」과 「성분이 빠져 못 세운 것」은 **할 일이 다르다**.
@@ -250,14 +270,21 @@ def _leaf_row(
if covered is not None:
# ⚠ **일부 몫만 선 단가는 안 붙인다.** 「인력(10%)·장비(90%)」 표에서 인력만
# 붙은 값을 전량에 곱하면 내역서가 조용히 틀린다 — 0 으로 때우는 것과 같은 사고다.
row.note = f"단가가 일부만 섰습니다 — 붙은 몫 {covered}% (나머지는 시공능력 공식 몫)."
# 무엇이 없어서 못 붙었는지까지 적는다 — 붙은 몫 0%」만으로는 어디를 손볼지 모른다.
why = unit_prices.component_gaps.get(node.code) or ""
missing_rows = unit_prices.unattached.get(node.code) or []
if not why and missing_rows:
why = f"{', '.join(missing_rows[:3])} 줄이 아직 안 붙었습니다"
row.note = (
f"단가가 일부만 섰습니다 — 붙은 몫 {covered}%" + (f" · {why}" if why else "") + "."
)
result.missing.append(
{
"name": row.name,
"code": node.code,
"unit": row.unit,
"quantity": str(item.quantity),
"reason": f"단가 일부만 섬(붙은 몫 {covered}%)",
"reason": f"단가 일부만 섬(붙은 몫 {covered}%)" + (f"{why}" if why else ""),
}
)
return row
@@ -300,6 +327,19 @@ def _leaf_row(
)
return row
# ⚠ **수량이 미확정 산식 위에 서 있는 줄**은 금액과 함께 그 사실을 싣는다.
# 금액이 커질수록 더 그렇다 — 지금 구조물터파기가 내역서에서 가장 큰 줄인데
# 그 밑수가 사용자 확정을 기다리고 있다(계획서 4-12 3단계).
pending = pending_formula_note(node.code)
if pending:
row.note = " / ".join(part for part in (row.note, pending) if part)
# ⚠ **원문에는 있는데 단가에 못 실린 몫**도 같은 자리에서 말한다. 금액이 서 있는 줄이라
# 표시가 없으면 완성된 값으로 읽힌다(규준틀 둘이 인력만으로 492만원이었다).
gap = known_gap_note(node.code)
if gap:
row.note = " / ".join(part for part in (row.note, gap) if part)
# 쓰인 차례를 기억한다 — 실무 참조번호(「단산 46」)가 그 차례다.
if price_code not in result.used_unit_prices:
result.used_unit_prices.append(price_code)
@@ -316,6 +356,32 @@ def _leaf_row(
return row
#: 수량 산식이 **사용자 확정을 기다리는** 공종 — 금액은 세우되 그 사실을 함께 싣는다.
#:
#: ⚠ **2026-09-09 저녁 비었다.** 걸려 있던 셋(구조물터파기·되메우기·잔토처리)이
#: **사용자 확정 5차로 닫혔다** — 「비탈 터파기, 지금 이대로」. 값은 안 바뀌었고
#: 11,263,899원이 **확정된 값**이 됐다.
#: ⚠ **표를 지우지 않고 비워 둔다** — 같은 성격의 자리가 또 생기면 여기 적으면 된다.
#: 적을 때는 **왜 대기인지**와 **정해지면 얼마나 움직이는지**를 함께 적을 것.
_PENDING_FORMULA: dict[str, str] = {}
from B09_Estimation.B09_Estimation_KnownGaps import ( # noqa: E402
known_gap_note,
pipe_diameter_note,
)
def pending_formula_note(code: str | None) -> str:
"""그 공종의 수량 산식이 확정 대기인가 — 맞으면 실을 문구."""
if not code:
return ""
for prefix, note in _PENDING_FORMULA.items():
if str(code).startswith(prefix):
return note
return ""
def _material_row(material: HandoffMaterial, result: BillResult) -> BillRow:
"""자재 한 줄. 공급 구분이 안 갈렸으면 **어느 쪽에도 안 넣는다**."""
row = BillRow(
@@ -0,0 +1,232 @@
"""B09 원가계산 — **품셈이 범위로 준 계수**를 사용자가 고르는 자리 (2026-09-09 확정 ①).
품셈이 계수를 ** 값으로 주고 범위로 주는 자리** 있다. 자리는 우리가 임의로
정한다 그런데 값이 없으면 공종은 금액이 통째로 선다(흙깎기가 그랬다).
9-3-2 흙깎기(기계) E = 0.550.45 지금 유일한 범위
**사용자 확정 (2026-09-09) E = 0.50 ( 끝의 평균).** 다만 사용자 지시가
붙었다: **값을 코드에 박고 끝내지 · 화면에 칸으로 세우고 근거를 보이고 바꿀
있게 .** 그래서 파일은 **값을 정하는 곳이 아니라 고를 것을 차리는 **이다.
고를 있는 것은 **원문에 적힌 끝과 평균 셋뿐**이다 밖의 수는 만들지 않는다.
** 평균이 기본인가** (화면이 그대로 보여 준다)
품셈 자신이 같은 값을 **다른 절에서 평균으로 쓴다** 9-13-4 용수토사가
`(0.55+0.45)/2-0.05`, 9-12-1 토사가 `(0.7+0.6)/2-0.05` 서식이다. 범위 표기와
평균 표기가 **같은 품셈 안에 섞여 있다.**
9-3-2 [] 사질토+점성토 ** ** 걸라 한다. 건설품셈 8-2-3 작업효율표의
자연상태·불량 칸이 모래·사질토 0.55 / 자갈섞인흙·점성토 0.45, 둘을
걸면 평균이 된다.
실무 **현행 산림품셈 인자(K 0.9 · f 1/1.30 · 20) 전부 맞는 것은 영월**
하나이고, 영월이 `E = (0.55+0.45)/2 = 0.50` 쓴다. 울진 둘은 건설품셈 조항을
근거로 달아 K·f 까지 다르다(K 0.7·f 1/1.25).
** 층은 프로젝트마다 갈린다** 저장은 프로젝트 설정의 `estimation` 구획이다.
**범위가 아닌 계수는 여기 오지 않는다.** `(0.7+0.6)/2-0.05` 같은 **** 품셈이 이미
값을 정한 것이라 그대로 계산한다 고를 것이 아니다.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from decimal import Decimal
from typing import Any
#: 범위 칸 — 「0.550.45」.
#: ⚠ **가운뎃점이 물결(∼·~·〜)일 때만 범위다.** 그냥 붙임표(`-`)는 **뺄셈**이다 —
#: 품셈 9-12-3 의 「0.45-0.05」는 「0.45 에서 0.05 를 뺀 0.40」이지 0.45~0.05 범위가
#: 아니다. 붙임표를 범위로 읽으면 **품셈이 이미 정한 값이 「고를 것」으로 둔갑한다**
#: (2026-09-09 실측: 세 자리가 그렇게 잡혔다).
_RANGE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*[~〜]\s*(\d+(?:\.\d+)?)\s*$")
#: 계수 이름 — 표 첫 칸이 이 중 하나일 때만 본다.
_FACTOR_HEADS = {
"k": "K",
"f": "f",
"e": "E",
"cm": "Cm",
"": "Cm",
"cm(sec)": "Cm",
"㎝(sec)": "Cm",
}
#: 고르는 방법 셋. **원문 두 끝과 그 평균뿐** — 다른 수는 만들지 않는다.
CHOICE_KEYS = ("high", "mid", "low")
DEFAULT_CHOICE = "mid"
@dataclass(frozen=True)
class RangeFactor:
"""품셈이 범위로 준 계수 한 자리."""
work_item_code: str
work_item_name: str
pum_table_id: str
factor: str
low: Decimal
high: Decimal
raw_cell: str
@property
def key(self) -> str:
return f"{self.work_item_code}:{self.factor}"
def value_of(self, choice: str) -> Decimal:
if choice == "high":
return self.high
if choice == "low":
return self.low
return (self.high + self.low) / Decimal(2)
def options(self) -> list[dict[str, Any]]:
return [
{
"key": "high",
"value": str(self.high),
"label": f"상한 {self.high}",
"note": "원문 범위의 큰 쪽 — 모래·사질토 자리",
},
{
"key": "mid",
"value": str(self.value_of("mid")),
"label": f"평균 {self.value_of('mid')}",
"note": "두 끝의 평균 — 품셈 자신이 다른 절에서 쓰는 서식이고 실무(영월)도 이 값",
},
{
"key": "low",
"value": str(self.low),
"label": f"하한 {self.low}",
"note": "원문 범위의 작은 쪽 — 자갈섞인흙·점성토 자리",
},
]
def _normalize_head(cell: Any) -> str:
return str(cell or "").strip().lower().replace(" ", "")
def scan_range_factors(master: dict[str, Any]) -> list[RangeFactor]:
"""품셈 전체에서 **범위로 적힌 계수 칸**을 모은다.
공종 코드를 박아 두지 않는다 품셈이 개정되면 범위 칸이 늘거나 있고,
코드로 잡으면 새로 생긴 자리를 조용히 놓친다.
"""
found: list[RangeFactor] = []
for node in master.get("work_items", []):
for table in node.get("tables", []):
for row in table.get("raw_row") or []:
cells = [str(cell).strip() for cell in row]
if not cells:
continue
factor = _FACTOR_HEADS.get(_normalize_head(cells[0]))
if factor is None:
continue
for cell in cells[1:]:
matched = _RANGE.match(str(cell))
if not matched:
continue
first, second = Decimal(matched.group(1)), Decimal(matched.group(2))
found.append(
RangeFactor(
work_item_code=str(node.get("work_item_code", "")),
work_item_name=str(node.get("name", "")),
pum_table_id=str(table.get("pum_table_id", "")),
factor=factor,
low=min(first, second),
high=max(first, second),
raw_cell=str(cell).strip(),
)
)
break
return found
def chosen_values(
factors: list[RangeFactor], settings: dict[str, Any] | None = None
) -> dict[tuple[str, str], Decimal]:
"""(공종코드, 계수) → 쓸 값. 저장분이 없으면 **평균**이 기본이다."""
stored = ((settings or {}).get("range_factor_choices") or {}) if settings else {}
values: dict[tuple[str, str], Decimal] = {}
for item in factors:
choice = str(stored.get(item.key) or DEFAULT_CHOICE)
if choice not in CHOICE_KEYS:
choice = DEFAULT_CHOICE
values[(item.work_item_code, item.factor)] = item.value_of(choice)
return values
#: 화면이 그대로 띄우는 근거. **왜 이 값인지**를 표가 스스로 말해야 한다(PLAN 8-13).
BASIS_NOTES: dict[str, list[str]] = {
"FP-09-03-02:E": [
"산림사업 표준품셈(고시 2025-82) 9-3-2 가 작업효율을 「0.550.45」 **범위**로 줍니다 — "
"한 값이 아니라 범위라 프로그램이 임의로 정하지 않습니다.",
"그 두 값의 정체는 건설공사 표준품셈 8-2-3 작업효율표의 **자연상태·불량** 칸입니다 — "
"「모래·사질토 0.55 / 자갈섞인흙·점성토 0.45」. 9-3-2 [주]③ 이 「사질토+점성토」 둘 다 "
"걸라 하므로 두 값이 함께 걸립니다.",
"품셈 자신이 같은 두 값을 다른 절에서는 평균으로 씁니다 — 9-13-4 용수토사 "
"「(0.55+0.45)/2-0.05」. 범위 표기와 평균 표기가 한 품셈 안에 섞여 있습니다.",
"실무 넷 중 현행 산림품셈 인자(K 0.9 · f 1/1.30 · ㎝ 20(135°))와 전부 맞는 것은 "
"영월 하나이고, 영월이 「E=(0.55+0.45)/2=0.50」 을 씁니다. 울진 둘은 건설품셈 옛 "
"조항(11-3 · 8-2-3)을 근거로 달아 K 0.7·f 1/1.25 까지 다릅니다.",
"⚠ 이 한 칸이 흙깎기 단가를 좌우합니다 — 0.45 면 약 659만원, 0.50 이면 약 593만원, "
"0.55 면 약 539만원(수량 2,355.84㎥ 기준).",
]
}
# ---------------------------------------------------------------------------
# 장비 규격 — 사용자 확정 ①에 딸려 온 지시(2026-09-09)
# ---------------------------------------------------------------------------
#
# ⚠ **두 종류가 섞여 있다. 섞어 다루면 안 된다.**
# ㉠ **표에 장비가 없는 자리** — 9-3-2 흙깎기가 그렇다. 장비는 [주]① 「장비는 무한궤도
# 굴착기(0.7㎥)를 적용한다」에 있는데 **마스터가 [주] 를 아직 안 싣는다.** 그래서
# 공식이 다 있어도 기종을 못 골라 금액이 통째로 안 섰다.
# ㉡ **표에 장비가 있는 자리** — 9-18 층따기는 표머리가 「굴착기 (무한궤도, 0.7㎥)」다.
# **원문이 정한 값**이라 기본은 그대로 두되, 실무가 다른 규격을 쓰는 것이 확인돼
# (영월 BACK-HOE 0.2㎥) 사용자가 바꿀 수 있어야 한다.
#
# ⚠ **㉠ 은 마스터가 [주] 를 실으면 이 표에서 지운다** — 두 곳에 같은 값을 두면 나중에
# 한쪽만 고쳐진다. 그때까지만 여기서 든다.
#: 기종 코드 → 화면에 보일 이름. 카탈로그가 정본이고 여기는 고르는 목록일 뿐이다.
MACHINE_OPTION_CODES = ("0201-0020", "0201-0070")
MACHINE_CHOICES: dict[str, dict[str, Any]] = {
"FP-09-03-02": {
"work_item_name": "흙깎기(기계)",
"default_code": "0201-0070",
"source": "note",
"basis": [
"산림사업 표준품셈 9-3-2 [주]① 「장비는 무한궤도 굴착기(0.7㎥)를 적용한다」 — "
"표가 아니라 [주] 에 있어 공종 마스터가 아직 못 싣는 값입니다.",
"[주]⑤ 가 그 까닭도 적습니다 — 「소규모공사(10,000㎥ 미만, 0.4㎥ 적용)이나 "
"암절취 깎기를 고려하여 0.7㎥ 적용한다」.",
],
},
"FP-09-18": {
"work_item_name": "층따기",
"default_code": "0201-0070",
"source": "table",
"basis": [
"산림사업 표준품셈 9-18 표머리가 「굴착기 (무한궤도, 0.7㎥)」로 장비를 정합니다 — "
"원문이 정한 값이라 기본은 이것입니다.",
"⚠ 실무는 더 작은 장비를 씁니다 — 영월 산출근거가 「층따기 BACK-HOE 0.2㎥ · "
"㎝ 20 sec(180°) · E 0.7」입니다. 현장이 좁은 자리라 실무가 달리 잡은 것으로 "
"보이며, 바꾸면 시간당 작업량이 줄어 단가가 오릅니다.",
],
},
}
def machine_choices(settings: dict[str, Any] | None = None) -> dict[str, str]:
"""공종코드 → 쓸 기종 코드. 저장분이 없으면 위 표의 기본값(원문 값)이다."""
stored = ((settings or {}).get("machine_choices") or {}) if settings else {}
picked: dict[str, str] = {}
for code, entry in MACHINE_CHOICES.items():
chosen = str(stored.get(code) or entry["default_code"])
picked[code] = chosen if chosen in MACHINE_OPTION_CODES else str(entry["default_code"])
return picked
+6 -1
View File
@@ -148,12 +148,17 @@ def check_operator_hours_basis(
daily_wage: Decimal,
person_days: Decimal = Decimal(1),
hours_per_day: int = 8,
allowance_factor: Decimal = Decimal(1),
) -> None:
"""㉣ 보조 — 조종원 노임 나눗수가 8시간인가.
나눗수를 몰래 줄이는 것이 효율을 사용료에 넣는 것과 같다.
`allowance_factor`(제수당·상여·퇴직충당 1.667) **나눗수와 다르다** 곱하는
자리를 드러내 놓고 검사에도 같이 넘긴다. 계수를 몰래 나눗수에 녹이면 검사가
통과해 버리므로, 계수는 **곱셈으로만** 들어와야 한다.
"""
expected = daily_wage * person_days / Decimal(hours_per_day)
expected = daily_wage * person_days / Decimal(hours_per_day) * allowance_factor
if abs(labor_per_hour - expected) > _TOLERANCE:
raise DoubleCountError(
f"조종원 시간당 노무비 {labor_per_hour:,.2f}"
+112
View File
@@ -0,0 +1,112 @@
"""B09 원가계산 — **원문에는 있는데 단가에 못 실린 몫** (2026-09-09).
반쪽 단가 표시는 여태 **이름을 카탈로그에서 찾은 ** 잡았다. 그물 밖에 둘이 있다.
표에 줄은 있는데 **값을 적는** 기초잡석 운반 | 덤프트럭(15ton)
표에 아예 없고 **[] 별도라 ** 규준틀 목재·표지판
**금액이 있는 **이라 표시가 없으면 **완성된 값으로 읽힌다**(2026-09-09 실측:
규준틀 둘이 4,921,267원인데 인력만의 값이었다).
**사유를 뭉뚱그리지 않는다.** 원문이 값을 **영영 막힌 **으로 읽히고,
거리 미정·설계수량 대기 ** 풀릴 **으로 읽힌다. 사용자가 보는 뜻이 다르다.
**여기 적는 것은 원문에 있는 말뿐이다.** 값을 만들지 않는다 무엇이 빠졌는지만 적는다.
**마스터가 [] 싣게 되면 표는 지운다** 곳에 같은 말을 두면 한쪽만 고쳐진다.
"""
from __future__ import annotations
#: 공종코드 → (짧은 딱지, 사유 한 줄). 접두사로 맞춘다(갈래가 붙어도 걸리게).
KNOWN_GAPS: dict[str, tuple[str, str]] = {
# ⚠ 규준틀 둘은 **B08 인계가 이미 「재료량은 [주]④ 설계수량에 따른다라 미확보」**를 싣는다.
# 그러니 그 말을 되풀이하지 않고 **B09 쪽에서만 아는 것**만 보탠다 —
# ㉠ 지금 선 값이 **인력 품만**이라는 것 ㉡ 손율은 원문에 이미 있다는 것.
# (2026-09-09: 처음엔 「표시가 아무것도 없다」고 봤는데, 인계 사유가 화면 문구 뒤쪽에
# 잘려 안 보였던 것이다. 잘린 자리를 사유가 없는 자리로 읽지 말 것.)
"FP-11-02": (
"인력 품만",
"ⓘ 이 단가는 **인력 품만**입니다 — 목재·못은 **자재 축**으로 따로 갑니다"
"(2026-09-09 각재·판재·못이 자재 축에 섰음. 다만 관급·사급이 안 갈려 아직 금액 0). "
"재료가 금액으로 서면 손율이 함께 걸립니다 — 품셈 [주]③ 「목재의 손율은 1개소 사용당 50%」.",
),
"FP-11-03": (
"인력 품만",
"ⓘ 이 단가는 **인력 품만**입니다 — 목재·못은 **자재 축**으로 따로 갑니다"
"(2026-09-09 각재·판재·못이 자재 축에 섰음. 다만 관급·사급이 안 갈려 아직 금액 0). "
"재료가 금액으로 서면 손율이 함께 걸립니다 — 품셈 [주]③ 「목재의 손율은 1개소 사용당 80%」.",
),
"FP-12-11-03": (
"인력 품만",
"⚠ 이 값은 **인력 품만**입니다 — 파형강관·커플링밴드·크레인(5ton)·모래부설이 아직 "
"안 붙었습니다. 관·모래는 원문이 「별산」·「설계수량」이라 한 자리이고, 크레인은 "
"기계 카탈로그에 없어서입니다. "
"ⓘ 커플링밴드는 값이 표에 없지만 [주]① 이 「**1EA/6m**」를 줍니다 — 자재가 서면 "
"m 당 1/6 EA 로 셀 수 있습니다(다만 「필요시 별도 산정」이라 조건이 하나 더 붙습니다).",
),
"FP-12-25": (
"운반거리 미정",
"⚠ 이 값에는 **운반 몫이 빠져 있습니다** — 품셈 12-25 는 「운반 | 덤프트럭(15ton)」 줄을 "
"두었으나 시간을 적지 않았습니다. 그 값은 품셈 10-12(덤프운반)가 **운반거리로** 냅니다"
"(같은 15ton 장비). 거리가 정해지면 사토 운반·덤프 운반과 **함께** 섭니다.",
),
}
#: 조건이 서면 **빠져야 하는** 줄 — 지금은 무조건 붙어 있다.
#: ⚠ 다른 조건부 줄들(계획서 9-9)과 달리 **이것은 금액이 서 있는 줄**이다.
CONDITIONAL_INCLUDED: dict[str, str] = {
"FP-12-25": (
"ⓘ 소할(할석공 0.06인)은 품셈 12-25 가 「**브레이커 사용할 때 제외**」라 적은 줄입니다 — "
"브레이커 갈래가 서면 이 몫이 빠져야 합니다. 지금은 붙어 있습니다."
),
}
def known_gap_note(code: str | None) -> str:
"""그 공종에 **원문에는 있는데 못 실린 몫**이 있으면 사유 한 줄."""
if not code:
return ""
plain = str(code).split("#")[0]
parts = []
for prefix, (_label, note) in KNOWN_GAPS.items():
if plain.startswith(prefix):
parts.append(note)
for prefix, note in CONDITIONAL_INCLUDED.items():
if plain.startswith(prefix):
parts.append(note)
return " / ".join(parts)
#: 관부설 품셈 표가 다루는 관경 — 그 밖은 **표에 없는 것**이지 값이 틀린 것이 아니다.
PIPE_TABLE_DIAMETERS_MM = (800, 1000, 1200)
#: 교본이 큰 관을 넘기는 자리 — **교본 기준이지 법령·행정규칙이 아니다.**
#: `횡단배수관_암거.md §5`(교본 3장) 「BOX암거 적용 — 수리계산상 배수관 **Ø1,500㎜ 이상**
#: 적용 유역 · 계곡 횡단경사 40% 이내 · 구체 수평 설치 원칙」.
#: ⚠ 그래서 「Ø1,500 이면 반드시 암거」라고 **단정하지 않는다** — 프로젝트 규칙이
#: 「기본값은 현행 법령·행정규칙, 교본은 과거 참조」이기 때문이다. **가리키기만** 한다.
CULVERT_HINT_MM = 1500
def pipe_diameter_note(code: str | None, variant_value: object) -> str:
"""관경이 품셈 표 밖일 때 **어디를 봐야 하는지**를 가리킨다.
품셈 표에 관경이 없음 적으면 사용자가 **엉뚱한 것을 정하러 간다**
정할 것은 단가가 아니라 **시설 선택** 있다.
"""
if not code or not str(code).startswith("FP-12-11"):
return ""
try:
diameter = int(float(str(variant_value)))
except (TypeError, ValueError):
return ""
if diameter in PIPE_TABLE_DIAMETERS_MM:
return ""
note = f"⚠ 품셈 관부설 표는 ∅800·∅1000·∅1200 까지만 있습니다 — Ø{diameter} 는 표 밖입니다."
if diameter >= CULVERT_HINT_MM:
note += (
" ⓘ 임도기술교본 3장은 **Ø1,500㎜ 이상을 BOX암거 적용 유역**으로 봅니다"
"(`횡단배수관_암거 §5`). 관으로 놓을 자리가 아닐 수 있으니 **단가가 아니라"
" 시설 선택**을 먼저 볼 것. ⚠ 교본 기준이라 법령·행정규칙은 아닙니다."
)
return note
+228
View File
@@ -0,0 +1,228 @@
"""B09 원가계산 — **목록표·집계표** (사용자 확정 12번: 내야 할 표 16개 전체).
지금까지 내던 일곱 여섯이 여기서 난다.
A5-1 중기목록표 코드·명칭·규격·단위 · **합계·노무비·재료비·경비** · 비고
A6 노무비목록표 코드·명칭·규격·단위 · **단가** · 비고
A7 재료비목록표
A8 경비목록표 (기계 취득가 `S-` 층이 여기 온다)
A11 자원 집계표 코드·명칭·규격 · **수량** · 단위 · 단가 · **금액** · 비고
노무비·재료비·경비·중기
**서식은 지어내지 않았다** 실무 내역서(영월 기번6 · 봉화 기번41) 같은 이름 시트를
그대로 옮겼다(2026-09-09 실측). 이름·차례가 시트와 같다.
** 계산이 아니다.** 목록표는 `PriceBook` 제목을 종류별로 늘어놓는 것이고,
집계표는 **내역서에 이미 금액을 자원별로 되모으는 **이다. 값을 여기서 다시 만들면
내역서와 어긋난다(CLAUDE.md 5 같은 계산을 벌로 짜지 않는다).
**집계표는 반올림**이다(단수 규칙 `RESOURCE_SUMMARY`). 내역서 본체는 절사라
** 표의 합이 단위로 어긋나는 것이 정상**이다 사실을 화면에 함께 낸다.
"""
from __future__ import annotations
from decimal import Decimal
from typing import Any
from B09_Estimation.B09_Estimation_PriceBook import PriceKind
from B09_Estimation.B09_Estimation_Rounding import (
SUMMARY_MISMATCH_NOTE,
OutputPlace,
round_at,
)
from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build
_ZERO = Decimal(0)
#: 목록표 한 장이 담는 종류. 실무 시트 이름 그대로 쓴다.
LIST_KINDS: tuple[tuple[str, str, PriceKind], ...] = (
("labor", "노무비목록표", PriceKind.LABOR),
("material", "재료비목록표", PriceKind.MATERIAL),
("expense", "경비목록표", PriceKind.MACHINE_BASE),
)
def _money(value: Decimal | None) -> str | None:
return None if value is None else str(value)
def catalog_list(build: UnitPriceBuild, kind: PriceKind) -> list[dict[str, Any]]:
"""목록표 한 장 — 그 종류의 **기초단가 줄**을 코드 차례로 늘어놓는다.
단가가 줄도 **빼지 않는다.** 빼면 없는 값을 구한 같아 보인다.
"""
rows: list[dict[str, Any]] = []
for code in sorted(build.book.titles):
title = build.book.titles[code]
if title.kind is not kind:
continue
try:
price: Decimal | None = title.adopted_price()
note = ""
except Exception as error: # 채택 슬롯이 비었다 — 값을 지어내지 않는다
price, note = None, str(error)
rows.append(
{
"code": code,
"name": title.name,
"spec": title.spec,
"unit": title.unit,
"unit_price_krw": _money(price),
"note": note,
}
)
return rows
def machine_base_list() -> list[dict[str, Any]]:
"""경비목록표 — **기계 취득가격(천원)** 목록.
`S-` 층과 **다른 **이다. `S-` 취득가 × 시간당 손료계수 **/시간**이고,
실무 경비목록표는 **취득가 자체를 천원 단위** 싣는다(영월 실측:
`S00104 불도저(무한궤도) 19 **천원** 184,499`). 손료를 여기 실으면 자릿수가 자리
어긋난 경비 읽힌다.
"""
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
catalog = load_machine_catalog()
rows: list[dict[str, Any]] = []
for code in sorted(catalog.machines):
machine = catalog.machines[code]
rows.append(
{
"code": f"S-{code}",
"name": machine.name,
"spec": machine.specification,
"unit": "천원",
"unit_price_krw": _money(machine.price_thousand_krw),
"note": "" if machine.loss_coefficient_per_hour is not None else "손료계수 미확보",
}
)
return rows
def machine_list(build: UnitPriceBuild) -> list[dict[str, Any]]:
"""중기목록표 — 시간당 사용료를 **3분할까지** 보인다 (실무 시트와 같은 칸).
실무 서식: `X00205 굴삭기(무한궤도) 0.7 시간 96,843 = 노무 55,700 + 재료 18,015 + 경비 23,128`
"""
rows: list[dict[str, Any]] = []
for code in sorted(build.book.titles):
title = build.book.titles[code]
if title.kind is not PriceKind.MACHINE_HOURLY:
continue
try:
money = build.book.resolve(code)
row = {
"total_krw": _money(round_at(money.total, OutputPlace.UNIT_PRICE_ROW)),
"labor_krw": _money(round_at(money.labor, OutputPlace.UNIT_PRICE_ROW)),
"material_krw": _money(round_at(money.material, OutputPlace.UNIT_PRICE_ROW)),
"expense_krw": _money(round_at(money.expense, OutputPlace.UNIT_PRICE_ROW)),
"note": "",
}
except Exception as error: # 층이 덜 섰다 — 0 으로 안 때운다
row = {
"total_krw": None,
"labor_krw": None,
"material_krw": None,
"expense_krw": None,
"note": str(error),
}
rows.append(
{"code": code, "name": title.name, "spec": title.spec, "unit": title.unit, **row}
)
return rows
def resource_summary(
quantities: dict[str, Decimal],
build: UnitPriceBuild | None = None,
) -> dict[str, Any]:
"""자원 집계표 — 공종 수량을 **자원별로 되모은다**.
`quantities` = `{공종코드: 수량}` (내역서가 쓰는 것과 같은 모양).
자원이 여러 공종에 걸리면 ** 줄로 합친다** 실무 시트가 모양이다.
**일위대가 안쪽을 겹만 편다.** 일위대가 자원(노무·자재·기계 사용료)까지가
실무 집계표의 깊이다. 기계 사용료(`X-`) 다시 손료·연료로 쪼개면 **중기 집계표와
이중으로 세는 ** 된다.
"""
prices = build or cached_build()
book = prices.book
#: 자원코드 → [수량, 제목]
picked: dict[str, list[Any]] = {}
missing: list[str] = []
for raw_code, quantity in quantities.items():
code = raw_code if raw_code.startswith("B-") else f"B-{raw_code}"
if code not in book.titles:
missing.append(raw_code)
continue
amount = Decimal(str(quantity))
for detail in book.details.get(code, []):
if detail.percent_of_labor is not None or detail.percent_of_parent is not None:
continue # 비율 줄은 자원이 아니다 — 경비로만 붙는다
ref = detail.ref_code
if ref == code:
continue
slot = picked.setdefault(ref, [_ZERO, book.titles.get(ref)])
slot[0] += detail.quantity * amount
groups: dict[str, list[dict[str, Any]]] = {
"labor": [],
"material": [],
"expense": [],
"machine": [],
}
for ref, (amount, title) in sorted(picked.items()):
if title is None:
missing.append(ref)
continue
bucket = {
PriceKind.LABOR: "labor",
PriceKind.MATERIAL: "material",
PriceKind.MACHINE_BASE: "expense",
PriceKind.MACHINE_HOURLY: "machine",
}.get(title.kind)
if bucket is None:
continue
try:
unit_money = book.resolve(ref)
unit_price: Decimal | None = unit_money.total
# ⚠ 집계표는 **반올림** — 내역서 본체(절사)와 원 단위로 어긋나는 것이 정상이다.
money: Decimal | None = round_at(
unit_money.total * amount, OutputPlace.RESOURCE_SUMMARY
)
note = ""
except Exception as error:
unit_price, money, note = None, None, str(error)
groups[bucket].append(
{
"code": ref,
"name": title.name,
"spec": title.spec,
"quantity": str(amount),
"unit": title.unit,
"unit_price_krw": _money(unit_price),
"amount_krw": _money(money),
"note": note,
}
)
return {
"groups": groups,
"missing": sorted(set(missing)),
"note": SUMMARY_MISMATCH_NOTE,
}
def all_lists(build: UnitPriceBuild | None = None) -> dict[str, Any]:
"""목록표 넷을 한 번에 — 화면이 탭 하나에서 다 쓴다."""
prices = build or cached_build()
return {
"labor": catalog_list(prices, PriceKind.LABOR),
"material": catalog_list(prices, PriceKind.MATERIAL),
"expense": machine_base_list(),
"machine": machine_list(prices),
}
@@ -0,0 +1,172 @@
"""B09 원가계산 — **자재단가대비표(A9) · 환율및기초자료(A10)**.
사용자 확정 12(내야 16) 남은 . · 벌이다.
A9 자재단가대비표 코드·명칭·규격·단위 · **원천 5(단가+쪽수)** · 적용(단가+출처) · 비고
A10 환율및기초자료 환율 인건비(운전사 3) 단가 재료비
**서식은 지어내지 않았다** 실무 내역서(영월 기번6) 같은 이름 시트를 그대로 옮겼다.
실측한 머리글:
코드번호 | 명칭 | 규격 | 단위 | 물가자료 | 물가정보 | 유통물가 | 거래가격등 | 기타단가 | 적용 | 비고
( 칸이 단가 · 페이지 )
적용 칸의 페이지 자리에는 **출처 약호** 온다 .·.·견적.
**원천 이름은 사무소마다 다르다**(PLAN 9-4 미결). 그래서 이름을 코드에 박지 않고
`PriceBook.slot_names` 그대로 쓴다 프로젝트가 고르면 머리도 따라 바뀐다.
**사용자 확정 자재값 출처는 **(업체 견적·지역 상차가 **** 물가지).
그래서 슬롯마다 **쪽수/업체명·날짜** 함께 자리를 낸다. 값이 없으면 **빈칸으로 두고
지어내지 않는다** 나중에 값인지 되짚어야 하기 때문이다.
**사용자 확정 유가는 전국 공시가/지역 공시가를 고를 있어야 한다.**
지금 자료(`oil_*.json`)에는 **전국평균 하나뿐**이라(`scope: national_average`),
**고르는 칸은 내되 지역값은 지어내지 않는다.** 고를 있는 것만 보이고, 지역을 고르면
아직 자료가 없다 밝힌다.
"""
from __future__ import annotations
from decimal import Decimal
from typing import Any
from B09_Estimation.B09_Estimation_MachineCost import (
OPERATOR_ALLOWANCE_FACTOR,
OPERATOR_ALLOWANCE_NOTICE,
)
from B09_Estimation.B09_Estimation_PriceBook import PRICE_SLOT_COUNT, PriceKind
from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build
#: 유가 적용 범위 — 사용자 확정 ⑮. 값이 있는 것만 고를 수 있다.
FUEL_SCOPES: tuple[dict[str, Any], ...] = (
{"key": "national_average", "label": "전국 공시가", "available": True},
{
"key": "regional",
"label": "지역 공시가",
"available": False,
"why": (
"오피넷 지역별 값을 아직 안 받아 왔습니다 — 품셈 8-1-7 5호가 「유류가격은 "
"해당 지역의 가격」이라 규정하므로 자료를 받으면 고를 수 있게 됩니다."
),
},
)
def _money(value: Decimal | None) -> str | None:
return None if value is None else str(value)
def material_price_comparison(build: UnitPriceBuild | None = None) -> dict[str, Any]:
"""자재단가대비표 — **원천을 나란히 두고 채택한 것을 표시**한다 (실무 서식 그대로).
값이 없는 슬롯은 **0 아니라 빈칸**이다. 0 넣으면 0원짜리 견적으로 읽힌다.
"""
prices = build or cached_build()
book = prices.book
rows: list[dict[str, Any]] = []
for code in sorted(book.titles):
title = book.titles[code]
if title.kind is not PriceKind.MATERIAL:
continue
slots: list[dict[str, Any]] = []
for index in range(PRICE_SLOT_COUNT):
value = title.slots[index] if index < len(title.slots) else None
page = title.slot_pages[index] if index < len(title.slot_pages) else None
slots.append(
{
"name": (
book.slot_names[index]
if index < len(book.slot_names)
else f"슬롯{index + 1}"
),
"price_krw": _money(value),
# 「페이지」 자리 — 물가지는 쪽수, 견적은 업체명·날짜가 온다(확정 ③).
"source_note": page or "",
"adopted": (index + 1) == title.adopted_slot,
}
)
try:
adopted: Decimal | None = title.adopted_price()
note = ""
except Exception as error: # 채택 슬롯이 비었다 — 지어내지 않는다
adopted, note = None, str(error)
rows.append(
{
"code": code,
"name": title.name,
"spec": title.spec,
"unit": title.unit,
"slots": slots,
"adopted_slot": title.adopted_slot,
"adopted_price_krw": _money(adopted),
"note": note,
}
)
return {
"slot_names": list(book.slot_names),
"rows": rows,
"notes": [
"원천을 나란히 두고 하나를 채택합니다 — 실무 자재단가대비표와 같은 서식입니다.",
"값이 없는 원천은 빈칸입니다. 0 으로 채우지 않습니다.",
"견적을 넣을 때는 업체명·견적일을 함께 남기십시오 — 국가계약법 시행령 §9 의 "
"4순위(앞의 셋으로 정할 수 없을 때)라 근거가 남아야 합니다.",
],
}
def base_reference_data(build: UnitPriceBuild | None = None) -> dict[str, Any]:
"""환율및기초자료 — 실무 시트 세 구획을 그대로 낸다.
환율 인건비(운전사 3) 단가 재료비(유류 )
"""
from B09_Estimation.B09_Estimation_MachineOperating import (
load_fuel_price,
load_operator_wages,
)
prices = build or cached_build()
fuel_price, fuel_meta = load_fuel_price()
wages = load_operator_wages()
# ⚠ **운전사 세 직종만** 싣는다. 실무 「환율및기초자료」 시트가 그 셋뿐이고
# (건설기계운전사·화물차운전사·일반기계운전사, 건설품셈 8-1-3 5~6호의 구분),
# 직종 전체를 실으면 **노무비목록표와 같은 표가 두 벌**이 된다.
operator_names = ("건설기계운전사", "화물차운전사", "일반기계운전사")
operators: list[dict[str, Any]] = []
for code, wage in sorted(wages.items()):
title = prices.book.titles.get(code)
if getattr(title, "name", "") not in operator_names:
continue
operators.append(
{
"code": code,
"name": getattr(title, "name", "") or code,
"day_wage_krw": _money(Decimal(str(wage))),
# 제수당·상여금·퇴직급여충당금 계수를 곱한 값 (2026-09-09 사용자 확정 ③).
# 근거·한계는 `MachineCost.OPERATOR_ALLOWANCE_FACTOR` 주석 한 곳에 모아 뒀다.
"hourly_krw": _money(Decimal(str(wage)) / Decimal(8) * OPERATOR_ALLOWANCE_FACTOR),
"formula": "일당 ÷ 8시간 × 16/12 × 25/20",
}
)
return {
"exchange": {
"rows": [],
"note": "수입 기계가 아직 없어 환율을 쓰는 자리가 없습니다 — 생기면 여기에 섭니다.",
},
"labor": {
"rows": operators,
"note": OPERATOR_ALLOWANCE_NOTICE,
},
"fuel": {
"diesel_krw_per_l": _money(fuel_price),
"scope": fuel_meta.get("scope", ""),
"effective_date": fuel_meta.get("effective_date", ""),
"dataset_id": fuel_meta.get("dataset_id", ""),
"scopes": list(FUEL_SCOPES),
"note": (
"품셈 8-1-7 5호 「유류가격은 해당 지역의 가격」 — 지역 공시가를 고를 수 있게 "
"칸을 두었으나 아직 전국 공시가만 받아 와 있습니다."
),
},
}
+68 -1
View File
@@ -48,6 +48,65 @@ _THOUSAND = Decimal(1000)
#: (★법대로 PLAN 8-10). TODO(미결 PLAN 9-6): 발주처가 실가동시간을 요구하는 사례 확인.
OPERATOR_HOURS_PER_DAY = 8
#: 조종원 노임에 붙는 **제수당·상여금·퇴직급여충당금** 계수 (= 16/12 × 25/20 ≒ 1.6667).
#:
#: 왜 붙나 — **공표 노임은 「기본급여액」일 뿐**이다. 대한건설협회 「임금적용요령」 4-나
#: (재경원 회계45101-45, 1995.1.13)가 「공표된 시중노임단가는 …기본급여액임. 따라서
#: 근로기준법에서 규정하고 있는 **제수당, 상여금 및 퇴직급여충당금**은 …회계예규인
#: 예정가격작성기준의 정한 바에 따라 계상하여야 함」이라고 못 박는다. 품셈 8-1-2 5.6호도
#: 운전사 노임을 「예정가격 작성기준(기획재정부 회계예규)에 의거 계상한다」로 넘긴다.
#: 그 예규가 정한 범위는 기재부 「(계약예규) 정부 입찰·계약 집행기준」 제76조의3
#: (노무비의 계상) — **노임 + 제수당 + 상여금(연 400% 한도) + 퇴직급여충당금**이다.
#:
#: ⚠ **예규 원문은 저장소에 없다.** 위 둘은 그 예규를 **인용한** 문서다. 그래서 「무엇을
#: 더하는가」는 원문으로 확인됐고 **「얼마를 더하는가」의 계수는 실무 관행을 따른다**
#: (2026-09-09 사용자 확정 ③ 「없으면 실무 기준으로 가되 노티스를 붙여 줄 것」).
#: · 16/12 — 상여금 연 400%(= 기본급 4개월분)를 12개월에 나눠 붙인 것과 **산술이 일치**한다.
#: · 25/20 — 월 지급일수 25 대 가동일수 20 으로 읽히나 **원문 확인 못 함**. 짐작을 적지 않는다.
#: 실물 셋이 같은 계수를 쓴다 — 실무 두 공사지(영월·봉화) 중기사용료 전수,
#: 임도교본 예제(`산림과임업기술(임도)/2. 임도/5. 설계.md:512`), 상용 적산 프로그램의
#: 기준계수표(`원가계산/다산소프트/ESTX_값사전_자재노무기계.md:427` `1/8*16/12*25/20`=0.20833).
#:
#: ⚠ **고용보험·산재보험·퇴직공제부금과 헷갈리지 말 것** — 그 셋은 노임 계수가 아니라
#: 원가계산 뒷단의 **요율 항목**으로 따로 선다(산재 3.56% · 고용 1.01~1.57% · 퇴직공제 2.3%,
#: `원가_입력변수_사전.md`). 여기에 겹쳐 넣으면 이중계상이 된다.
#: ⚠ **기계 감가상각도 여기가 아니다** — 상각비는 손료(경비) 쪽이다(품셈 8-1-5 1호).
OPERATOR_ALLOWANCE_FACTOR = (Decimal(16) / Decimal(12)) * (Decimal(25) / Decimal(20))
#: 화면·표가 그대로 띄우는 노티스 한 줄. 계수를 쓴 자리마다 같은 문구가 서야 한다.
OPERATOR_ALLOWANCE_NOTICE = (
"조종원 노임에 제수당·상여금·퇴직급여충당금 계수 1.667배(16/12 × 25/20)를 넣었습니다 — "
"공표 노임은 기본급여액뿐이라 별도 계상해야 합니다(건협 임금적용요령 4-나, "
"기재부 정부 입찰·계약 집행기준 제76조의3). ⚠ 계수 자체의 예규 원문은 아직 못 봐 "
"실무 관행(실무 두 공사지·임도교본 예제·상용 적산 프로그램이 같은 계수)을 따랐습니다."
)
#: ⚠ **원천이 뭉개 놓은 줄을 원문으로 되살린다** (2026-09-09).
#:
#: `mach_base_2026.json` 의 대형 브레이커 여섯 줄은 **이름 칸에 표 전체가 뭉쳐** 들어가
#: 규격이 비고 손료계수가 없다. 그래서 「대형브레이커」로 찾아지지도, 시간당 사용료가
#: 서지도 않았다 — 구조물터파기(암절취)가 그 때문에 통째로 막혀 있었다.
#:
#: 값은 **건설공사 표준품셈 제8장 (0230) 대형 브레이커** 표에서 읽었고, **두 번 검증**했다.
#: ① 계수 합이 맞는다 — 상각 3,000 + 정비 2,833 + 관리 768 = **6,601** (표의 「계」와 같다)
#: ② 같은 방식으로 읽은 굴착기(0201) 표의 「계 2,085」가 카탈로그의 손료계수
#: **0.0002085 와 정확히 일치**한다 — 열 배치를 잘못 읽지 않았다는 증거다.
#:
#: ⚠ **원천이 이 줄을 제대로 싣게 되면 이 표는 지운다.** 두 곳에 같은 값을 두면
#: 나중에 한쪽만 고쳐진다. 취득가는 원천 값을 그대로 쓴다 — 여기서는 **이름·규격·손료계수**만 채운다.
MASHED_MACHINE_FIXES: dict[str, tuple[str, str]] = {
"0230-0002": ("대형 브레이커", "0.2"),
"0230-0004": ("대형 브레이커", "0.4"),
"0230-0006": ("대형 브레이커", "0.6"),
"0230-0007": ("대형 브레이커", "0.7"),
"0230-0008": ("대형 브레이커", "0.8"),
"0230-0010": ("대형 브레이커", "1.0"),
}
#: (0230) 표의 「시간당 계」 — 규격이 달라도 같은 값이다(원문 여섯 줄 모두 6,601).
MASHED_LOSS_COEFFICIENT = Decimal("0.0006601")
class MachineCostError(LookupError):
"""기계경비를 세울 수 없는 경우. 0 으로 때우지 않고 멈춘다."""
@@ -116,6 +175,11 @@ def load_machine_catalog(file_name: str = "mach_base_2026.json") -> MachineCatal
for row in variables.get("mach_price", {}).get("records", []):
code = row["machine_code"]
coefficient = coefficients.get(code, {})
fixed = MASHED_MACHINE_FIXES.get(code)
if fixed:
# 뭉개진 줄 — 원문으로 이름·규격을 되살리고 손료계수를 채운다.
row = {**row, "machine_name": fixed[0], "specification": fixed[1]}
coefficient = {**coefficient, "loss_coefficient_per_hour": MASHED_LOSS_COEFFICIENT}
catalog.machines[code] = MachineSpec(
machine_code=code,
name=row["machine_name"],
@@ -164,6 +228,7 @@ def hourly_machine_cost(
fuel_price_per_liter: Decimal | None = None,
operator_daily_wage: Decimal | None = None,
operator_hours_per_day: int = OPERATOR_HOURS_PER_DAY,
operator_allowance_factor: Decimal = OPERATOR_ALLOWANCE_FACTOR,
efficiency_factor: Decimal | None = None,
) -> HourlyMachineCost:
"""시간당 사용료 한 시간분.
@@ -192,12 +257,14 @@ def hourly_machine_cost(
# TODO(미결 PLAN 9-6): `mach_operator_map` 0건 — 기종별 운전사 직종이 품셈 본문에만 있다.
gaps.append("운전사 직종 매핑 미확보 — 노무비 성분 비어 있음")
else:
labor = operator_daily_wage / Decimal(operator_hours_per_day)
# 나눗수는 8시간 그대로 두고 **계수를 곱한다** — 나눗수를 줄이는 것과 다르다.
labor = (operator_daily_wage / Decimal(operator_hours_per_day)) * operator_allowance_factor
# ㉣ 보조 — 나눗수를 몰래 줄이면 효율을 사용료에 넣은 것이 된다.
check_operator_hours_basis(
labor_per_hour=labor,
daily_wage=operator_daily_wage,
hours_per_day=operator_hours_per_day,
allowance_factor=operator_allowance_factor,
)
return HourlyMachineCost(
@@ -196,11 +196,18 @@ def _capacity_token(inside: str) -> str:
def extract_cycle_factors(
work_item_code: str,
table: dict[str, Any],
choices: dict[tuple[str, str], Decimal] | None = None,
machines: dict[str, str] | None = None,
) -> CycleFactors | FactorGap | None:
"""표 하나에서 계수를 뽑는다.
공식 계수가 하나도 없으면 `None`( 표는 공식형이 아니다), 일부만 있으면
`FactorGap`, 있으면 `CycleFactors`.
`choices` 품셈이 **범위로 계수**(9-3-2 `E = 0.550.45`) 사용자가 고른 값을
끼워 넣는다. **범위가 아닌 칸은 절대 덮는다** 품셈이 값을 정한 자리를 사용자
설정이 밀어내면 그것이 임의 수치다. 고를 있는 것도 원문 끝과 평균뿐이다
(`B09_Estimation_FactorChoices`).
"""
rows = table.get("raw_row") or []
values: dict[str, Decimal] = {}
@@ -255,8 +262,29 @@ def extract_cycle_factors(
if not saw_key and machine is None:
return None
# 범위 칸이라 못 읽은 자리에만 **고른 값**을 끼운다 — 읽힌 칸은 손대지 않는다.
for key in ("K", "f", "E", "Cm"):
if values.get(key) is None and choices:
picked = choices.get((work_item_code, key))
if picked is not None:
values[key] = picked
saw_key = True
missing = [key for key in ("K", "f", "E", "Cm") if values.get(key) is None]
capacity = bucket_from_machine_row
# 고른 기종이 있으면 그것을 쓴다. 표가 장비를 말한 자리(층따기)에서는 **바꾸는 것**이고,
# 표에 장비가 없는 자리(흙깎기)에서는 [주] 에만 있는 값을 **채우는 것**이다.
# 어느 쪽이든 화면이 근거와 함께 보이고 사용자가 되돌릴 수 있다(확정 ① 딸림 지시).
picked_code = (machines or {}).get(work_item_code)
if picked_code:
chosen = load_machine_catalog().machines.get(picked_code)
if chosen is not None:
machine = (picked_code, chosen.name)
spec = parse_measure(chosen.specification)
if spec is not None:
capacity = spec
if machine is None:
missing.append("기계")
if capacity is None:
@@ -308,6 +336,9 @@ def attach_machine_share(
master: dict[str, Any],
work_item_code: str,
title_code: str,
choices: dict[tuple[str, str], Decimal] | None = None,
machines: dict[str, str] | None = None,
sources: dict[str, str] | None = None,
) -> Decimal:
"""시공능력 공식(8-1-4)으로 **장비 몫**을 붙인다. 붙인 비율(%)을 돌려준다.
@@ -327,7 +358,7 @@ def attach_machine_share(
from B09_Estimation.B09_Estimation_PriceBook import PriceDetail
for table in node.get("tables", []):
factors = extract_cycle_factors(work_item_code, table)
factors = extract_cycle_factors(work_item_code, table, choices, machines)
if not isinstance(factors, CycleFactors):
if isinstance(factors, FactorGap):
factor_gaps[work_item_code] = factors
@@ -352,7 +383,13 @@ def attach_machine_share(
title_code,
hourly_code,
machine_hours_per_unit(factors) * share,
note=factors.formula_text,
# 계수를 남의 절에서 빌려 왔으면 **그 사실을 줄 비고에 적는다.**
note=factors.formula_text
+ (
f" · {(sources or {}).get(work_item_code, '')}"
if (sources or {}).get(work_item_code)
else ""
),
)
)
cycle_factors[work_item_code] = factors
@@ -0,0 +1,302 @@
"""B09 원가계산 — **「다른 절과 동일」 참조**를 따라가 계수를 잇는다 (2026-09-09).
품셈은 같은 계수를 되풀이 적지 않고 **다른 절을 가리킨다.**
9-13-1 육상토사(01m) 장비(90%) 유압식백호우 | k 0.9 | f 0.77 | E 0.60 | 20(135°)
9-13-2 육상토사(12m) 장비(90%) 유압식백호우 | **육상토사(01m) 동일**
9-13-10 용수 암절취(01m) 들어내기 | k 0.55 **육상과동일**
자리를 따라가면 **장비 90% 통째로 붙고 인력 10% 선다** 2026-09-09
실측으로 구조물터파기 여덟 갈래가 전부 모양이었다(단가가 일부만 섰습니다 붙은 10%).
**값을 옮겨 적지 않는다.** 가리키는 절의 계수를 **그때그때 읽어** 쓴다. 옮겨 적으면
품셈이 개정될 한쪽만 고쳐진다.
**어디서 값인지 남긴다.** 화면이 9-13-1 동일(품셈 원문) 그대로 보여야
나중에 누가 봐도 근거를 되짚을 있다(오늘 규칙).
** 따라가는 참조는 따라간 척하지 않는다.**
· **자기 자신을 가리키는 ** 9-13-14 육상 발파암(12m) 동일이라 적었는데
절이 육상 발파암(12m)이다(원문 오기로 보이나 **고쳐 읽지 않는다**).
· **가리키는 절을 찾는 · 절도 계수가 없는 .**
셋은 사유를 남기고 ** 채로 둔다.**
"""
from __future__ import annotations
import re
from decimal import Decimal
from typing import Any
#: 「…와 동일」 — 앞의 이름이 가리키는 절이다.
_NAMED = re.compile(r"^(?P<name>.+?)\s*(?:와|과)\s*동일$")
#: 「육상과동일」 — 이름이 아니라 **한 낱말만 바꾸라**는 지시다(용수 → 육상).
_SWAP_WORDS = (("용수", "육상"),)
#: 이 이름들만 계수로 본다. 참조가 가리키는 것도 결국 이 넷이다.
_FACTOR_HEADS = {
"k": "K",
"f": "f",
"e": "E",
"cm": "Cm",
"": "Cm",
"cm(sec)": "Cm",
"㎝(sec)": "Cm",
}
def _clean(cell: Any) -> str:
return " ".join(str(cell or "").split())
def _normalize_name(text: str) -> str:
"""절 이름 비교용 — 공백과 물결표기 차이를 지운다(「0~1m」·「0-1m」)."""
return re.sub(r"[\s~〜–—-]", "", str(text))
def _row_has_machine(cells: list[str]) -> bool:
"""그 줄이 **기계 줄**인가 — 계수가 와야 할 자리인지 가른다."""
from B09_Estimation.B09_Estimation_MachineProductivity import resolve_machine
return any(resolve_machine(cell) is not None for cell in cells)
def _find_reference(node: dict[str, Any]) -> tuple[str, str] | None:
"""이 절이 가리키는 이름과 그 원문 문구. 참조가 없으면 `None`.
**기계 줄에 붙은 참조만 본다.** 안에 참조가 이상 있고 **가리키는 곳이
서로 다르다** 9-13-11 치즐소모량 육상과동일 들어내기 유압식백호우
용수 암절취(01m) 동일 함께 적는다. 아무 줄에서나 주우면 **용수 자리에 육상
계수** 붙어 작업효율이 0.375 대신 0.50 으로 서고 금액이 조용히 틀린다
(2026-09-09 실측으로 잡았다).
"""
own_name = str(node.get("name", ""))
for table in node.get("tables", []):
for row in table.get("raw_row") or []:
cells = [_clean(cell) for cell in row]
if not _row_has_machine(cells):
continue
for cell in cells:
text = _clean(cell)
if not text or len(text) > 40:
continue
# ⚠ **낱말 바꾸기를 먼저 본다.** 「육상과동일」은 「육상」이라는 절을
# 가리키는 것이 아니라 **제 이름에서 용수를 육상으로 바꾸라**는 뜻이다.
# 이름 규칙(「…와 동일」)을 먼저 태우면 「육상」이라는 없는 절을 찾다가
# 놓친다(2026-09-09 실측: 네 갈래가 그렇게 빠졌다).
for source, target in _SWAP_WORDS:
# 문구에 적힌 낱말은 **가리키는 쪽**(육상)이고, 제 이름에 있는 낱말이
# **바꿀 쪽**(용수)이다. 둘을 뒤집어 보면 영영 못 찾는다.
if text in (f"{target}과동일", f"{target}과 동일") and source in own_name:
return own_name.replace(source, target), text
matched = _NAMED.match(text)
if matched:
return matched.group("name").strip(), text
return None
def _factor_values(node: dict[str, Any]) -> dict[str, Decimal]:
"""그 절이 **스스로 적어 둔** 계수들. 참조는 안 따라간다(한 걸음만 간다)."""
from B09_Estimation.B09_Estimation_MachineProductivity import parse_measure
values: dict[str, Decimal] = {}
for table in node.get("tables", []):
for row in table.get("raw_row") or []:
cells = [_clean(cell) for cell in row]
if not cells:
continue
for index, cell in enumerate(cells):
factor = _FACTOR_HEADS.get(cell.lower().replace(" ", ""))
if factor is None or factor in values:
continue
for candidate in cells[index + 1 :]:
parsed = parse_measure(candidate)
if parsed is not None:
values[factor] = parsed
break
return values
def reference_factor_values(
master: dict[str, Any],
) -> tuple[dict[tuple[str, str], Decimal], dict[str, str], dict[str, str], dict[str, str]]:
"""참조를 따라가 얻은 계수들.
돌려주는 (공종코드, 계수) · 공종코드 근거 · 공종코드 따라간
사유 · 공종코드 **원문 참조 문구 그대로**( 줄을 붙은 목록에서 빼는 쓴다).
"""
nodes = {str(n.get("work_item_code", "")): n for n in master.get("work_items", [])}
by_name: dict[str, list[str]] = {}
for code, node in nodes.items():
by_name.setdefault(_normalize_name(node.get("name", "")), []).append(code)
values: dict[tuple[str, str], Decimal] = {}
provenance: dict[str, str] = {}
failures: dict[str, str] = {}
raw_texts: dict[str, str] = {}
def resolve(code: str, seen: tuple[str, ...]) -> tuple[dict[str, Decimal], list[str], str]:
"""그 절의 계수를 푼다 — 스스로 적은 것 + 참조를 따라간 것.
**참조는 사슬로 이어진다** 9-13-11(용수 암절취 1~2m) 육상과동일
9-13-8 가리키고, 절은 다시 육상 암절취(0~1m) 동일 9-13-7
가리킨다. 걸음만 가면 가운데서 멈춘다(2026-09-09 실측).
** 자리는 멈춘다** 자기 자신이나 이미 지나온 절로 돌아가면 사슬이 도는
것이라 따라간 척하지 않는다.
"""
node = nodes.get(code)
if node is None:
return {}, [], f"공종 {code} 을 못 찾았습니다"
own = _factor_values(node)
if len(own) >= 4:
return own, [], ""
found = _find_reference(node)
if found is None:
return own, [], ""
target_name, raw_text = found
matches = [m for m in by_name.get(_normalize_name(target_name), []) if m != code]
if not matches:
return own, [], f"{raw_text}」가 가리키는 절을 못 찾았습니다"
if len(matches) > 1:
return own, [], f"{raw_text}」가 가리키는 절이 여럿입니다 — 하나로 못 좁혔습니다"
target = matches[0]
if target in seen:
return own, [], f"{raw_text}」가 이미 지나온 절을 다시 가리킵니다 — 사슬이 돕니다"
borrowed, path, why = resolve(target, (*seen, code))
if why:
return own, [], f"{raw_text}」를 따라갔으나 {why}"
merged = {**borrowed, **own}
missing = [key for key in ("K", "f", "E", "Cm") if key not in merged]
if missing:
return own, [], f"{raw_text}」를 따라갔으나 계수가 없습니다 — {', '.join(missing)}"
step = f"{raw_text}」 → {nodes[target].get('name', target)}"
return merged, [step, *path], ""
for code, node in nodes.items():
if _find_reference(node) is None:
continue
own = _factor_values(node)
if len(own) >= 4:
continue # 스스로 다 적어 둔 절 — 참조는 곁말이다
merged, path, why = resolve(code, ())
if why:
failures[code] = why
continue
for key, value in merged.items():
if key not in own:
values[(code, key)] = value
provenance[code] = "계수 출처: " + " · ".join(path) + " (품셈 원문 표기 그대로)"
own_ref = _find_reference(node)
if own_ref:
raw_texts[code] = own_ref[1]
return values, provenance, failures, raw_texts
# ---------------------------------------------------------------------------
# 작업량을 **직접 준** 기계 줄 (2026-09-09)
# ---------------------------------------------------------------------------
#
# 품셈은 기계 몫을 늘 공식으로만 주지 않는다. **시간당 작업량을 바로 적는** 줄이 있다.
#
# ['장비 (90%)', '깨기', '대형브레이커(㎥/hr)', '3.5', 'Q=(3.2+3.8)/2 (연암평균치 적용)']
#
# 이 줄을 못 읽으면 암·발파암 갈래의 **깨기 몫이 통째로 빠진다** — 들어내기(백호우)만
# 붙어 「일부만 선 단가」로 남는다.
#
# ⚠ **단위가 붙어 있을 때만 읽는다.** 「(㎥/hr)」·「(m/hr)」처럼 시간당 작업량임을
# 표가 스스로 밝힌 줄만 본다. 숫자만 있는 칸을 작업량으로 넘겨짚지 않는다.
_CAPACITY_UNIT = re.compile(r"[(]\s*(㎥|m3|㎡|m2|m|ton|t)\s*/\s*(?:hr|시간)\s*[)]")
def _paired_machine_spec(node: dict[str, Any]) -> str:
"""그 표에 함께 나오는 기종의 규격(「유압식백호우 (무한궤도,0.7㎥)」 → 0.7)."""
from B09_Estimation.B09_Estimation_MachineProductivity import resolve_machine
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
catalog = load_machine_catalog()
for table in node.get("tables", []):
for row in table.get("raw_row") or []:
for cell in row:
found = resolve_machine(_clean(cell))
if found is not None:
machine = catalog.machines.get(found[0])
if machine is not None and machine.specification:
return str(machine.specification)
return ""
def _machine_by_name(text: str, preferred_spec: str) -> tuple[str, str] | None:
"""이름만으로 기종을 고른다 — 규격이 여럿이면 **짝의 규격**을 따른다.
대형브레이커(/hr) 괄호가 **규격이 아니라 단위** 보통 길로는 풀린다.
"""
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
wanted = re.sub(r"\s", "", text)
if not wanted:
return None
catalog = load_machine_catalog()
hits = [
(code, machine)
for code, machine in catalog.machines.items()
if wanted and wanted in re.sub(r"\s", "", machine.name)
]
if not hits:
return None
if preferred_spec:
narrowed = [item for item in hits if str(item[1].specification) == str(preferred_spec)]
if len(narrowed) == 1:
return narrowed[0][0], narrowed[0][1].name
return (hits[0][0], hits[0][1].name) if len(hits) == 1 else None
def direct_capacity_rows(node: dict[str, Any]) -> list[dict[str, Any]]:
"""그 공종에서 **시간당 작업량을 직접 준 기계 줄**들.
돌려주는 기계 이름 · 기종 코드/이름 · 시간당 작업량 · 묶음 배분율(%) · 원문 문구.
"""
from B09_Estimation.B09_Estimation_MachineProductivity import parse_measure
found: list[dict[str, Any]] = []
ratio: Decimal | None = None
# 같은 표에 짝이 되는 기종이 있으면 **그 규격**을 따른다 — 「대형브레이커」는 규격을
# 안 적고, 실무도 「대형브레이커 + B/H 0.7」처럼 붙는 굴착기 규격으로 잡는다.
paired_spec = _paired_machine_spec(node)
for table in node.get("tables", []):
for row in table.get("raw_row") or []:
cells = [_clean(cell) for cell in row]
if not cells:
continue
seen_ratio = re.search(r"[(]\s*(\d+(?:\.\d+)?)\s*%\s*[)]", cells[0])
if seen_ratio:
ratio = Decimal(seen_ratio.group(1))
for index, cell in enumerate(cells):
if not _CAPACITY_UNIT.search(cell):
continue
machine = _machine_by_name(_CAPACITY_UNIT.sub("", cell).strip(), paired_spec)
if machine is None:
continue
capacity = next(
(parse_measure(token) for token in cells[index + 1 :] if parse_measure(token)),
None,
)
if capacity is None or capacity <= 0:
continue
found.append(
{
"cell": cell,
"machine_code": machine[0],
"machine_name": machine[1],
"capacity_per_hour": capacity,
"ratio_pct": ratio,
"table_id": str(table.get("pum_table_id", "")),
# 그 줄의 칸들 — 「못 붙은 줄」 목록에서 이 줄을 걷어내는 데 쓴다.
"row_cells": [c for c in cells if c],
}
)
return found
@@ -350,6 +350,10 @@ def _resolve_cell(catalog: ResourceCatalog, name_cell: str, cells: list[str]):
return None
#: 공종 단위로 인정하지 않는 말 — **품의 단위**(사람·날)이지 물리 수량이 아니다.
_NOT_A_WORK_ITEM_UNIT = frozenset({"", "인당", "", "일당", "인/일"})
def match_table(
node: dict[str, Any],
table: dict[str, Any],
@@ -370,6 +374,30 @@ def match_table(
if match_crew_table(node, table, catalog, result, table.get("basis_unit") or ""):
return
# ⚠ **축이 셋인 표도 형태 판정보다 먼저 가른다.** 값 한 칸에 세로축 값이 여럿
# 뭉쳐 있어 행-자원으로도 열-자원으로도 안 읽히고, 콘테이너형 가설건축물(11-1)은
# `reference` 로 찍혀 형태 필터에 먼저 걸려 버려지고 있었다(확정 ⑬ 이 걸린 표).
from B09_Estimation.B09_Estimation_ResourceAxis_ThreeAxis import match_three_axis_table
if match_three_axis_table(node, table, catalog, result):
return
# ⚠ **「둘 중 하나를 고르는」 장비 블록 표도 먼저 가른다.** 그냥 읽으면 블록을 다 더해
# 장비 두 대·인부 두 몫이 서서 대략 두 배가 된다(2026-09-09 제근 128,039원).
from B09_Estimation.B09_Estimation_ResourceAxis_ChooseOne import (
match_choose_one_machine_table,
)
if match_choose_one_machine_table(node, table, catalog, result):
return
# ⚠ **규격이 열로 선 표**(관부설 12-11)도 여기서 가른다 — 값이 「0.62/2.5」 같은
# 나눗셈이라 보통 길로는 한 줄도 안 선다.
from B09_Estimation.B09_Estimation_ResourceAxis_ChooseOne import match_spec_column_table
if match_spec_column_table(node, table, catalog, result):
return
form = table.get("pum_form", "")
if form in NON_WORK_ITEM_FORMS or form in UNUSABLE_FORMS or form not in USABLE_FORMS:
result.skipped_forms[form] = result.skipped_forms.get(form, 0) + 1
@@ -378,6 +406,13 @@ def match_table(
basis = table.get("basis_quantity")
basis_quantity = None if basis is None else Decimal(str(basis))
unit = table.get("basis_unit") or ""
# ⚠ **「인」은 공종 단위가 아니다** — 사람 수다. 마스터가 본문·표에서 「인」을 밑수로
# 읽어 온 자리가 있고(드론방제·지상방제·야면석 채집·휘발유), 그대로 두면 「몇 인짜리
# 공종」이라는 뜻이 되어 **단위 불일치 검사가 엉뚱하게 통과**한다.
# ⚠ 원문 고치기는 마스터 쪽 몫(2026-09-09 데스크탑 메인) — 여기서는 **받는 쪽에서 막는다.**
# 짝 규칙은 `B09_Estimation_WorkItemUnit` 머리말에 이미 적어 둔 것과 같다.
if unit.strip() in _NOT_A_WORK_ITEM_UNIT:
unit = ""
# ⚠ **한 표에 밑수가 둘인 표가 있다** — 「㎡당 0.17 / ㎥당 0.64」(채집 13-2 계열).
# 행-자원으로 읽으면 **앞줄만 잡고 뒷줄을 버린다** — 막돌 채집이 ㎡당 값을 ㎥ 단위로
@@ -403,6 +438,11 @@ def match_table(
if match_packed_rows(node, table, catalog, result, basis_quantity, unit):
return
# ⚠ **묶음 배분율은 다음 줄로 이어진다.** 품셈 표는 묶음 머리를 **병합해** 적는다 —
# 「인력(10%) | 할석공 2.0」 다음 줄이 「보통인부 1.0」이라 그 줄엔 딱지가 없다.
# 이어 주지 않으면 그 줄만 **100%로 서서** 조용히 열 배가 된다(2026-09-09 실측:
# 구조물터파기 보통인부가 0.1 대신 1.0 으로 서 단가가 224,305원/㎥ 이었다).
carried_ratio: Decimal | None = None
for index, row in enumerate(table.get("raw_row", [])):
cells = [str(c) for c in row]
if not cells:
@@ -427,6 +467,10 @@ def match_table(
group_ratio = _group_ratio_of(name_cell)
name_cell = cells[1]
value_cells = cells[2:]
# 새 묶음 머리를 만났다 — 여기서부터 이 배분율이 이어진다(없으면 끊는다).
carried_ratio = group_ratio
else:
group_ratio = carried_ratio
# 제잡비 비율 줄 — 자원이 아니라 **노무비에 붙는 경비율**이다(품셈 [주]③).
if "제잡비" in _normalize(name_cell):
@@ -0,0 +1,316 @@
"""B09 원가계산 — **표가 「둘 중 하나를 고르라」고 둔 장비 블록** 읽기 (2026-09-09).
품셈에는 같은 일을 **장비 규격에 따라 달리 세는** 표가 있다. 블록이 둘인데 ** 더하면
장비 대와 인부 몫이 서서 대략 ** 된다.
9-21 제근
| | | 단위 | | | |
| 굴착기(무한궤도) | 굴착기(무한궤도,0.2) | hr | 0.80 | 1.01 | 1.22 | 0.2 블록
| 보통인부 | | 0.03 | 0.04 | 0.05 |
| 굴착기(무한궤도,0.7) | hr | 0.46 | 0.58 | 0.70 | 0.7 블록
| 보통인부 | | 0.03 | 0.04 | 0.05 |
2026-09-09 실측: 제근 단가가 **128,039**으로 있었다 굴착기 0.2·0.7 붙고
보통인부도 붙은 값이다. 밑수가 원문에 없어 아직 금액이 있었을 ,
**밑수가 정해지는 조용히 배로 자리**였다.
읽는 **블록마다 갈래 하나**, 열마다 갈래 하나. 둘을 곱해 갈래를 낸다.
갈래 = 굴착기(무한궤도) 0.2 · 굴착기(무한궤도) 0.7 · (2 × 3 = 6)
**고르는 인지 아닌지를 좁게 가른다.** 기계 줄이 둘이라고 고르는 표가 아니다
9-13 암절취는 깨기(대형브레이커) 들어내기(백호우) **함께 드는** 표다.
가르는 자국은 **같은 기계 이름에 규격만 다른 **이다(굴착기 0.2 vs 0.7). 품셈도 그렇게
말한다 9-20-1 [] 0.2 또는 0.4 용량의 굴착기를 사용하는 경우에는 적용계수를
달리 적용하도록 한다.
**밑수는 여기서 만들지 않는다.** 9-21 머리·제목·[] 어디에도 밑수가 없다
(2026-09-09 원문 전수 확인). 갈래만 바로 세우고 밑수는 채로 둔다.
"""
from __future__ import annotations
import re
from decimal import Decimal
from typing import Any
from B09_Estimation.B09_Estimation_ResourceAxis import (
AxisResult,
CatalogEntry,
ResourceCatalog,
ResourceRow,
UnmatchedRow,
parse_amount,
split_name_and_spec,
)
_NUMBER = re.compile(r"^\d+(?:\.\d+)?$")
#: 열 머리로 인정하지 않는 말 — 값이 아니라 설명이다.
_NOT_A_COLUMN = ("비고", "적요", "참고", "단위", "명칭", "명 칭", "종류", "종 류", "규격", "규 격")
def _clean(cell: Any) -> str:
return " ".join(str(cell or "").split())
def _column_labels(header: list[Any]) -> list[str]:
"""표 머리에서 **갈래 열 이름**만 골라 낸다 — 「소·중·밀」."""
labels = [_clean(cell) for cell in header]
return [
label
for label in labels
if label and "".join(label.split()) not in {"".join(w.split()) for w in _NOT_A_COLUMN}
]
def _machine_of(cells: list[str], catalog: ResourceCatalog):
"""그 줄이 기계 줄이면 (칸 번호, 기종, 원문 칸). 아니면 `None`.
자원 카탈로그의 이름·규격 짝으로는 풀린다 품셈이 굴착기(무한궤도,0.2)처럼
**규격을 괄호 안에 몰아** 적기 때문이다. 기종 해석은 모양을 아는 쪽에 맡긴다.
"""
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
from B09_Estimation.B09_Estimation_MachineProductivity import resolve_machine
machines = load_machine_catalog().machines
for index, cell in enumerate(cells):
found = resolve_machine(cell)
if found is None:
continue
machine = machines.get(found[0])
if machine is None:
continue
entry = CatalogEntry(
code=found[0], name=machine.name, kind="machine", spec=str(machine.specification)
)
return index, entry, cell
return None
def _values_of(cells: list[str], count: int) -> list[Decimal] | None:
"""그 줄 끝에서 값 `count` 개. 개수가 안 맞으면 `None` — 짐작해 채우지 않는다."""
numbers = [cell for cell in cells if _NUMBER.match(cell)]
if len(numbers) < count:
return None
picked = [parse_amount(cell) for cell in numbers[-count:]]
return None if any(value is None for value in picked) else picked # type: ignore[return-value]
def match_choose_one_machine_table(
node: dict[str, Any],
table: dict[str, Any],
catalog: ResourceCatalog,
result: AxisResult,
) -> bool:
"""「둘 중 하나를 고르는」 장비 블록 표를 읽는다. 그런 표가 아니면 `False`.
같은 기계 이름에 **규격만 다른** 블록이 이상일 때만 표로 본다.
"""
# ⚠ **기계 카탈로그가 없는 조립에서는 이 표를 읽지 않는다.** 기종 해석은 기계 쪽
# 카탈로그를 직접 보므로, 노무만 든 카탈로그로 돌릴 때도 기계 줄이 나와 버린다
# (2026-09-09 시험이 그것을 잡았다). **넘겨받은 카탈로그의 결을 따른다.**
if not any(entry.kind == "machine" for entry in catalog.entries):
return False
rows = [row for row in (table.get("raw_row") or []) if isinstance(row, list)]
header = list(table.get("condition_note") or [])
labels = _column_labels(header)
if len(rows) < 2 or len(labels) < 2:
return False
blocks: list[dict[str, Any]] = []
for row in rows:
cells = [_clean(cell) for cell in row]
if not cells:
continue
found = _machine_of(cells, catalog)
if found is not None:
_index, entry, raw_cell = found
values = _values_of(cells, len(labels))
if values is None:
return False
blocks.append({"entry": entry, "cell": raw_cell, "rows": [], "values": values})
continue
if not blocks:
continue
name, spec = split_name_and_spec(cells[0])
entry = catalog.resolve(name, spec)
values = _values_of(cells, len(labels))
if entry is None or values is None:
continue
blocks[-1]["rows"].append({"entry": entry, "values": values, "cell": cells[0]})
if len(blocks) < 2:
return False
# ⚠ **같은 이름 · 다른 규격**일 때만 「고르는 표」다. 이름이 다르면 함께 드는 장비다.
names = {block["entry"].name for block in blocks}
specs = {block["entry"].spec for block in blocks}
if len(names) != 1 or len(specs) != len(blocks):
return False
work_item_code = str(node.get("work_item_code", ""))
table_id = str(table.get("pum_table_id", ""))
form = str(table.get("pum_form", ""))
unit = table.get("basis_unit") or ""
# ⚠ **빌려 온 밑수의 배수를 여기서 나눈다** — 「1,000㎡당」 표를 ㎡당으로 싣는다.
# 안 나누면 금액이 **천 배**로 선다(2026-09-09 확정 5차 6번, 제근).
from B09_Estimation.B09_Estimation_WorkItemUnit import borrowed_basis_per
per = Decimal(borrowed_basis_per(str(node.get("work_item_code", ""))))
made = 0
for block in blocks:
machine = block["entry"]
for column, label in enumerate(labels):
variant = f"{machine.name} {machine.spec} · {label}".strip()
entries = [(machine, block["values"][column], block["cell"])]
entries += [
(item["entry"], item["values"][column], item["cell"]) for item in block["rows"]
]
for entry, amount, cell in entries:
result.rows.append(
ResourceRow(
work_item_code=work_item_code,
pum_table_id=table_id,
pum_form=form,
resource_kind=entry.kind,
resource_code=entry.code,
resource_name=entry.name,
resource_spec=entry.spec,
amount=amount / per,
amount_unit=unit,
raw_row_index=0,
variant=variant,
)
)
made += 1
del cell
if made == 0:
return False
result.unmatched.append(
UnmatchedRow(
work_item_code=work_item_code,
pum_table_id=table_id,
cell=" | ".join(_clean(cell) for cell in header),
reason=(
f"장비 규격 {len(blocks)} 가지 × 갈래 {len(labels)} 가지로 세웠습니다 — "
"표가 「둘 중 하나」로 둔 자리라 **더하지 않고 고르게** 합니다. "
"⚠ 밑수(무엇당)는 원문에 없습니다."
),
)
)
return True
# ---------------------------------------------------------------------------
# 규격이 **열**로 선 표 (2026-09-09) — 관부설 12-11-1·2·3
# ---------------------------------------------------------------------------
#
# | 구 분 | 규격 | 단위 | 관 경 별 적 용 |
# | ∅800mm | ∅1000mm | ∅1200mm | ← 첫 줄이 **규격 이름만** 늘어선 줄
# | 크레인 | 10ton | hr | 0.62/2.5 | 0.76/2.5 | 0.90/2.5 |
# | 배관공 | | 인 | 0.26/2.5 | 0.35/2.5 | 0.46/2.5 |
#
# ⚠ 값이 **나눗셈 식**이다 — 「0.62/2.5」는 「관 2.5m 한 개당 0.62시간」이라는 뜻이라
# **m 당으로 환산된 값**이다. 그대로 두면 자원 줄이 하나도 안 서서 배수관이 통째로
# 금액을 못 냈다(2026-09-09 실측: 아홉 줄 중 다섯이 길이까지 있는데 단가가 없었다).
#
# ⚠ **값이 빈 칸은 건너뛴다** — 기초콘크리트·거푸집·모래부설은 「별산」 자리다(계획서 9-9).
_SPEC_HEAD = re.compile(r"^[∅Ø⌀]\s*\d")
_FRACTION = re.compile(r"^\s*(\d+(?:\.\d+)?)((?:\s*/\s*\d+(?:\.\d+)?)+)\s*$")
def _fraction_value(cell: str) -> Decimal | None:
"""「0.62/2.5」·「0.016/2.5/2」를 수로. 나눗셈이 아니면 `None`."""
matched = _FRACTION.match(cell)
if not matched:
return None
value = Decimal(matched.group(1))
for part in matched.group(2).split("/"):
part = part.strip()
if not part:
continue
divisor = Decimal(part)
if divisor == 0:
return None
value = value / divisor
return value
def _cell_amount(cell: str) -> Decimal | None:
"""값 칸 하나 — 숫자 그대로이거나 나눗셈 식."""
text = _clean(cell)
if not text:
return None
if _NUMBER.match(text):
return parse_amount(text)
return _fraction_value(text)
def match_spec_column_table(
node: dict[str, Any],
table: dict[str, Any],
catalog: ResourceCatalog,
result: AxisResult,
) -> bool:
"""규격이 **열**로 선 표를 읽는다. 그런 표가 아니면 `False`.
줄이 **규격 이름만** 늘어선 줄일 때만 표로 본다 800mm 1000mm .
"""
rows = [row for row in (table.get("raw_row") or []) if isinstance(row, list)]
if len(rows) < 2:
return False
specs = [_clean(cell) for cell in rows[0] if _clean(cell)]
if len(specs) < 2 or not all(_SPEC_HEAD.match(spec) for spec in specs):
return False
unit = table.get("basis_unit") or ""
work_item_code = str(node.get("work_item_code", ""))
table_id = str(table.get("pum_table_id", ""))
form = str(table.get("pum_form", ""))
made = 0
for index, row in enumerate(rows[1:], start=1):
cells = [_clean(cell) for cell in row]
if not cells:
continue
name, spec_text = split_name_and_spec(cells[0])
entry = catalog.resolve(name, spec_text) or catalog.resolve(name, "")
if entry is None:
result.unmatched.append(
UnmatchedRow(
work_item_code=work_item_code,
pum_table_id=table_id,
cell=cells[0],
reason="카탈로그에 없는 이름 (규격이 열로 선 표) — 0 으로 때우지 않습니다.",
)
)
continue
values = [_cell_amount(cell) for cell in cells[1:]]
values = [value for value in values if value is not None]
if len(values) < len(specs):
# 값이 빈 규격이 있다 — 「별산」 줄이거나 표가 덜 찼다. 짐작해 채우지 않는다.
continue
for spec_name, amount in zip(specs, values[-len(specs) :]):
result.rows.append(
ResourceRow(
work_item_code=work_item_code,
pum_table_id=table_id,
pum_form=form,
resource_kind=entry.kind,
resource_code=entry.code,
resource_name=entry.name,
resource_spec=entry.spec,
amount=amount,
amount_unit=unit,
raw_row_index=index,
variant=spec_name,
)
)
made += 1
return made > 0
@@ -0,0 +1,297 @@
"""B09 원가계산 — **축이 셋인 표** 읽기 (자원 축 보조, 2026-09-09).
품셈에는 안에 **가로축 · 세로축 · 자원** 함께 모양이 있다. 칸에
세로축 여러 개가 **공백으로 뭉쳐** 들어 있어, -자원으로도 -자원으로도 읽힌다.
11-1. 콘테이너형 가설건축물 확정 걸려 있던
| 길이 | 3M | 6M | | 비고 |
| | 비계공 특별인부 | 비계공 특별인부 | | |
| 2.4M 3.0M 3.5M 4.8M 6.0M | 0.29 0.33 | 0.14 0.17 | |
13-5-1. 돌붙임(인력) 덤으로 같이 서는
| | | |
| | 깬돌 | 깬잡석 | 야면석 | 깬돌 | 깬잡석 | 야면석 |
| 뒷길이() | 석공 | 보통인부 | |
| 25 30 35 | 0.15 0.22 | |
** 지금 줄도 서고 있었다** 하나는 `reference` 걸러졌고(F0325), 하나는
뭉친 자원 줄의 이름을 풀었습니다 버려졌다(F0416).
읽는 ** 아랫줄이 값이고, 위가 자원 이름이고, 위가 묶음 이름**이다.
값줄 칸을 쪼갠다 세로축 N (2.4M 3.0M 5 )
값줄 나머지 칸은 저마다 **N 개의 숫자** 들고 있어야 한다 아니면 통째로 버린다
값줄 바로 위가 자원 이름 , () 묶음 이름
i j 번째 숫자 = (묶음 라벨 · 세로축 j 번째 ) 갈래의 자원 i 소요량
**자리를 짐작해 맞추지 않는다.** ·숫자 개수가 나뉘지 않으면 ** 줄도 세우지
않고** `unmatched` 보낸다. 뭉친 표는 칸만 밀려도 **다른 규격의 ** 붙는다.
**- 값이 없는 **이다. 0 으로 때우지 않고 갈래만 건너뛴다
(품셈 13-5-1 야면석 70 자리가 그렇다 규격이 없다는 뜻이다).
"""
from __future__ import annotations
import re
from decimal import Decimal
from typing import Any
from B09_Estimation.B09_Estimation_ResourceAxis import (
AxisResult,
ResourceCatalog,
ResourceRow,
UnmatchedRow,
parse_amount,
split_name_and_spec,
)
#: 값 칸으로 인정하는 글자 — 숫자와 「-」(없음)뿐이다.
_NUMBER = re.compile(r"^\d+(?:\.\d+)?$")
_ABSENT = ("-", "", "", "", "", "", "·")
#: 묶음 이름 자리에서 뺄 말. 「비고」 열은 값이 아니라 설명이다.
_NOTE_LABELS = ("비고", "적요", "참고")
#: 세로축 이름이 안 적힌 표를 위한 자리표시 — **지어낸 이름을 쓰지 않는다.**
_UNNAMED_AXIS = "구분"
def _clean(text: Any) -> str:
return " ".join(str(text or "").split())
def _tokens(cell: Any) -> list[str]:
return _clean(cell).split()
def _is_value_cell(cell: Any, count: int) -> bool:
"""숫자(또는 「-」)만 `count` 개 든 칸인가."""
parts = _tokens(cell)
if len(parts) != count:
return False
return all(_NUMBER.match(p) or p in _ABSENT for p in parts)
def _axis_values(cell: Any) -> list[str]:
"""세로축 값들. 「2.4M 3.0M …」·「25 30 35 …」처럼 한 칸에 뭉쳐 있다."""
parts = _tokens(cell)
if len(parts) < 2:
return []
# 값 축이어야 한다 — 이름이 뭉친 줄(자원 이름 여럿)을 값으로 오해하면 안 된다.
if not all(re.match(r"^\d", p) for p in parts):
return []
return parts
def _labelled_cells(row: list[Any], width: int) -> list[str] | None:
"""줄에서 **값 칸에 대응하는 칸들**만 골라 낸다. 못 고르면 `None`.
줄머리( 이름) 칸이 **있는 줄과 없는 줄이 섞여 있다** 11-1 자원 줄은
비계공으로 바로 시작하고, 13-5-1 자원 줄은 뒷길이 () 시작한다.
앞칸을 무조건 버리면 자원 하나가 통째로 사라진다(11-1 그래서 7 8 어긋났다).
그래서 **있는 그대로 세어 보고, 맞으면 앞칸 하나를 줄머리로 보고 다시 센다.**
"""
cells = [_clean(cell) for cell in row if _clean(cell)]
cells = [cell for cell in cells if cell not in _NOTE_LABELS]
if not cells:
return None
if len(cells) == width or (width and len(cells) % width == 0):
return cells
if len(cells) - 1 == width or (width and (len(cells) - 1) % width == 0):
return cells[1:]
return None
def _group_labels(row: list[Any], width: int, prefix: str = "") -> list[str] | None:
"""묶음 줄 하나를 **열 개수만큼** 펼친다. 딱 나뉘지 않으면 `None`.
| 열두 개를 반씩 먹는 모양을 여기서 편다.
**묶음 줄은 칸을 먼저 떼고 센다** 자리는 이름( · ·
길이 )이지 묶음이 아니다. 떼면 묶음 하나로 서서 **·찰이
통째로 사라진다**(2026-09-09 실측). 떼고도 나뉘면 그때 통째로 세어 본다.
"""
labels = [_clean(cell) for cell in row if _clean(cell)]
labels = [label for label in labels if label not in _NOTE_LABELS]
for candidate in (labels[1:], labels):
if candidate and width % len(candidate) == 0:
span = width // len(candidate)
spread: list[str] = []
for label in candidate:
spread.extend([f"{prefix} {label}".strip() if prefix else label] * span)
return spread
return None
def _axis_name(rows: list[list[Any]], header: list[Any], resource_row_index: int) -> str:
"""세로축 이름 — 값줄 바로 위 첫 칸(「뒷길이 (㎝)」)이 먼저다.
자리가 자원 이름이면(11-1 비계공 온다) 머리 칸의 ** 낱말** 쓴다
(길이 가로축 이름이 , 세로축 이름이 뒤인 품셈 머리 관례).
"""
if resource_row_index > 0:
candidate = _clean(rows[resource_row_index][0])
if candidate and not _tokens(candidate)[0].isdigit():
return candidate # 「뒷길이 (㎝)」 — 단위는 값 옆으로 옮겨 붙인다
names = _split_axis_names(header[0] if header else "")
if names:
return names[1]
return _UNNAMED_AXIS
def _split_axis_names(cell: Any) -> tuple[str, str] | None:
"""모서리 칸이 **축 이름 둘**인가 — 「길이 폭」이면 (길이, 폭), 「구 분」이면 아니다.
품셈 머리에는 **자간을 벌린 낱말** 흔하다( · · ).
낱말 하나를 둘로 읽으면 갈래 이름이 처럼 망가진다(2026-09-09 실측).
가르는 자리는 **글자 ** 벌려 낱말은 토막이 모두 글자다.
"""
parts = _tokens(cell)
if len(parts) != 2:
return None
if all(len(part) == 1 for part in parts):
return None
return parts[0], parts[1]
def _axis_label(axis_name: str, value: str) -> str:
"""「뒷길이 (㎝)」 + 「25」 → 「뒷길이 25㎝」. 이름에 딸린 단위를 값 옆으로 옮긴다."""
match = re.match(r"^(.*?)\s*[(]\s*([^)]+?)\s*[)]\s*$", axis_name)
if match:
return f"{match.group(1).strip()} {value}{match.group(2).strip()}"
return f"{axis_name} {value}"
def match_three_axis_table(
node: dict[str, Any],
table: dict[str, Any],
catalog: ResourceCatalog,
result: AxisResult,
) -> bool:
"""축이 셋인 표를 읽는다. 그런 표가 아니면 `False` — 원래 길로 보낸다.
형태만 보고 가른다. **공종 코드를 박아 두지 않는다** 품셈이 개정되면
번호가 움직이므로 코드로 잡으면 조용히 놓친다.
"""
raw_rows = [row for row in (table.get("raw_row") or []) if isinstance(row, list)]
if len(raw_rows) < 2:
return False
header = list(table.get("condition_note") or [])
data_row = raw_rows[-1]
axis_values = _axis_values(data_row[0] if data_row else "")
if not axis_values:
return False
# 값 칸 — 세로축 값 개수만큼 숫자를 든 칸만 값으로 본다.
# ⚠ **꼬리의 설명 칸은 떼어 낸다** — 「비고」 열에 「H=2.6M 기준 용도: 사무실, 창고」
# 같은 글이 온다(11-1). 그 칸까지 값으로 세면 표 전체를 못 읽는다. 다만 **떼는 것은
# 꼬리뿐**이다 — 가운데가 값이 아니면 자리를 단정할 수 없으므로 통째로 버린다.
cells = [cell for cell in data_row[1:] if _clean(cell)]
while cells and not _is_value_cell(cells[-1], len(axis_values)):
cells.pop()
value_cells = cells
if len(value_cells) < 2 or not all(_is_value_cell(c, len(axis_values)) for c in value_cells):
return False
# 자원 이름 줄 — 값줄 바로 위. 빈 칸은 표 끝의 여백이라 버린다.
resource_row = raw_rows[-2]
names = _labelled_cells(resource_row, len(value_cells)) or []
if len(names) != len(value_cells):
result.unmatched.append(
UnmatchedRow(
work_item_code=node.get("work_item_code", ""),
pum_table_id=str(table.get("pum_table_id", "")),
cell=" | ".join(_clean(c) for c in resource_row),
reason=(
f"축이 셋인 표인데 자원 이름 {len(names)} 개와 값 칸 {len(value_cells)} 개가 "
"맞지 않습니다 — 자리를 단정할 수 없어 한 줄도 세우지 않았습니다."
),
)
)
return True
# 묶음 줄 — 표 머리(condition_note)와 자원 줄 위의 raw_row 들. 위에서 아래 차례로 쌓는다.
group_rows: list[list[Any]] = []
if header:
group_rows.append(header)
group_rows.extend(raw_rows[: len(raw_rows) - 2])
# 가로축 이름 — 「길이 폭」의 앞 낱말. 열 라벨이 「3M」뿐이라 이름이 없으면
# 갈래가 「3M」으로만 남아 무엇의 3M 인지 안 보인다.
head_names = _split_axis_names(header[0] if header else "")
column_axis = head_names[0] if head_names else ""
spreads: list[list[str]] = []
for index, row in enumerate(group_rows):
prefix = column_axis if (index == 0 and header and row is header) else ""
spread = _group_labels(row, len(value_cells), prefix)
if spread is None:
result.unmatched.append(
UnmatchedRow(
work_item_code=node.get("work_item_code", ""),
pum_table_id=str(table.get("pum_table_id", "")),
cell=" | ".join(_clean(c) for c in row),
reason=(
"축이 셋인 표인데 묶음 이름이 열 개수로 딱 나뉘지 않습니다 — "
"짐작해 맞추지 않고 한 줄도 세우지 않았습니다."
),
)
)
return True
spreads.append(spread)
axis_name = _axis_name(raw_rows, header, len(raw_rows) - 2)
unit = table.get("basis_unit") or ""
form = str(table.get("pum_form", ""))
work_item_code = node.get("work_item_code", "")
table_id = str(table.get("pum_table_id", ""))
# ⚠ **자원 줄이 정말 자원 줄인지 먼저 본다.** 축이 넷인 표(10-6-3 기타 임업자재)는
# 값줄 바로 위가 **단위 줄**(「인/㎥」·「인/100속」)이라 자원 이름이 하나도 안 풀린다.
# 그런 표는 **내 표가 아니다** — 못 맞춤에 적지 않고 원래 길로 돌려보낸다.
resolved = [catalog.resolve(*split_name_and_spec(cell)) for cell in names]
if not any(entry is not None for entry in resolved):
return False
made = 0
for column, (name_cell, value_cell) in enumerate(zip(names, value_cells)):
entry = resolved[column]
if entry is None:
result.unmatched.append(
UnmatchedRow(
work_item_code=work_item_code,
pum_table_id=table_id,
cell=name_cell,
reason="자원 이름을 카탈로그에서 못 찾았습니다 — 0 으로 때우지 않습니다.",
)
)
continue
labels = [spread[column] for spread in spreads]
for index, token in enumerate(_tokens(value_cell)):
if token in _ABSENT:
# 「-」 는 **그 규격이 없다는 뜻** — 0 으로 세우면 공짜 공종이 된다.
continue
amount = parse_amount(token)
if amount is None:
continue
variant = " · ".join([*labels, _axis_label(axis_name, axis_values[index])])
result.rows.append(
ResourceRow(
work_item_code=work_item_code,
pum_table_id=table_id,
pum_form=form,
resource_kind=entry.kind,
resource_code=entry.code,
resource_name=entry.name,
resource_spec=entry.spec,
amount=amount,
amount_unit=unit,
raw_row_index=len(raw_rows) - 1,
variant=variant,
)
)
made += 1
return made > 0
+220 -2
View File
@@ -201,7 +201,7 @@ async def list_unit_price_titles(project_id: UUID) -> JSONResponse:
알아야 하기 때문이다(자재 카탈로그 미확보로 구조물 계열이 ).
"""
try:
build = cached_build()
build = await _build_for(project_id)
return JSONResponse(
content={
"status": "success",
@@ -217,11 +217,229 @@ async def list_unit_price_titles(project_id: UUID) -> JSONResponse:
)
async def _project_root_of(project_id: UUID) -> str | None:
"""프로젝트 저장 폴더. 못 찾으면 `None` — 그때는 확정 기본값으로 돈다."""
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import run_with_connection
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
try:
stored = await run_with_connection(get_project_storage_relative_path, project_id)
return str(resolve_stored_project_path(stored))
except Exception:
logger.warning("B09 프로젝트 폴더를 못 찾았습니다 — 기본값으로 돕니다: %s", project_id)
return None
async def _build_for(project_id: UUID):
"""그 프로젝트가 **고른 값**으로 조립한 일위대가.
범위 계수(작업효율)·장비 규격은 프로젝트마다 다를 있다(확정 ). 전역 벌로
돌면 프로젝트에서 바꾼 값이 다른 프로젝트 금액까지 흔든다.
"""
from common_util.common_util_project_settings import estimation_settings
root = await _project_root_of(project_id)
settings = estimation_settings(root) if root else {}
ranges = tuple(
sorted((str(k), str(v)) for k, v in (settings.get("range_factor_choices") or {}).items())
)
machines = tuple(
sorted((str(k), str(v)) for k, v in (settings.get("machine_choices") or {}).items())
)
return cached_build(ranges, machines)
@router.get("/{project_id}/estimation/base-data")
async def get_base_data_lists(project_id: UUID) -> JSONResponse:
"""**기초자료 네 표** — 노무비·재료비·경비 목록표 + 중기목록표 (사용자 확정 12번).
별표2 설계서 구성에 드는 표들이라 **없으면 설계서가 성립하지 않는다.** 서식은
실무 내역서(영월 기번6·봉화 기번41) 같은 이름 시트를 그대로 따랐다.
"""
from B09_Estimation.B09_Estimation_Lists import all_lists
try:
return JSONResponse(
content={"status": "success", **all_lists(await _build_for(project_id))}
)
except Exception:
logger.exception("B09 기초자료 목록 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "기초자료 목록을 못 만들었습니다."},
)
@router.get("/{project_id}/estimation/price-sources")
async def get_price_sources(project_id: UUID) -> JSONResponse:
"""**자재단가대비표(A9) · 환율및기초자료(A10)** — 사용자 확정 12번의 남은 둘.
(자재값 출처 )·(유가 전국/지역) 벌이라 **출처를 고르는 **
**업체명·날짜·쪽수 자리** 함께 낸다.
"""
from B09_Estimation.B09_Estimation_Lists_Sources import (
base_reference_data,
material_price_comparison,
)
try:
build = await _build_for(project_id)
return JSONResponse(
content={
"status": "success",
"material_comparison": material_price_comparison(build),
"base_reference": base_reference_data(build),
}
)
except Exception:
logger.exception("B09 단가 원천 표 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "단가 원천 표를 못 만들었습니다."},
)
@router.get("/{project_id}/estimation/factors")
async def get_factor_choices(project_id: UUID) -> JSONResponse:
"""**산출 조건** — 품셈이 범위로 준 계수와 장비 규격 (사용자 확정 ① 딸림 지시).
값을 코드에 박고 끝내지 · 화면에 칸으로 세우고 근거를 보이고 바꿀 있게
라는 지시대로, **지금 · 고를 있는 · 값인지** 함께 낸다.
"""
from B09_Estimation.B09_Estimation_FactorChoices import (
BASIS_NOTES,
DEFAULT_CHOICE,
MACHINE_CHOICES,
MACHINE_OPTION_CODES,
machine_choices,
scan_range_factors,
)
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
from B09_Estimation.B09_Estimation_UnitPrice import load_work_item_master
from common_util.common_util_project_settings import estimation_settings
try:
root = await _project_root_of(project_id)
settings = estimation_settings(root) if root else {}
stored = settings.get("range_factor_choices") or {}
ranges = []
for item in scan_range_factors(load_work_item_master()):
choice = str(stored.get(item.key) or DEFAULT_CHOICE)
ranges.append(
{
"key": item.key,
"work_item_code": item.work_item_code,
"work_item_name": item.work_item_name,
"factor": item.factor,
"raw_cell": item.raw_cell,
"chosen": choice,
"value": str(item.value_of(choice)),
"is_default": choice == DEFAULT_CHOICE,
"options": item.options(),
"basis": BASIS_NOTES.get(item.key, []),
}
)
catalog = load_machine_catalog()
picked = machine_choices(settings)
machines = []
for code, entry in MACHINE_CHOICES.items():
options = []
for machine_code in MACHINE_OPTION_CODES:
machine = catalog.machines.get(machine_code)
if machine is None:
continue
options.append(
{
"key": machine_code,
"label": f"{machine.name} {machine.specification}".strip(),
}
)
machines.append(
{
"work_item_code": code,
"work_item_name": entry["work_item_name"],
"chosen": picked.get(code, entry["default_code"]),
"default": entry["default_code"],
"is_default": picked.get(code) == entry["default_code"],
"source": entry["source"],
"options": options,
"basis": entry["basis"],
}
)
return JSONResponse(
content={
"status": "success",
"ranges": ranges,
"machines": machines,
"notes": [
"고를 수 있는 것은 원문에 적힌 값뿐입니다 — 그 밖의 수는 만들지 않습니다.",
"바꾸면 그 공종 단가가 바로 달라집니다. [저장]한 값은 이 프로젝트에만 걸립니다.",
],
}
)
except Exception:
logger.exception("B09 산출 조건 조회 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "산출 조건을 못 불러왔습니다."},
)
class FactorChoiceBody(BaseModel):
"""고른 값 — 안 보낸 칸은 그대로 둔다."""
range_factor_choices: dict[str, str] | None = None
machine_choices: dict[str, str] | None = None
@router.put("/{project_id}/estimation/factors")
async def put_factor_choices(project_id: UUID, body: FactorChoiceBody) -> JSONResponse:
"""산출 조건을 이 프로젝트에 저장한다. **다른 구획은 손대지 않는다.**"""
from B09_Estimation.B09_Estimation_FactorChoices import CHOICE_KEYS, MACHINE_OPTION_CODES
from common_util.common_util_project_settings import save_section
root = await _project_root_of(project_id)
if root is None:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."},
)
values: dict[str, Any] = {}
if body.range_factor_choices is not None:
# ⚠ 모르는 값은 안 받는다 — 원문에 없는 수가 설정으로 들어오면 그것이 임의 수치다.
values["range_factor_choices"] = {
str(key): str(value)
for key, value in body.range_factor_choices.items()
if str(value) in CHOICE_KEYS
}
if body.machine_choices is not None:
values["machine_choices"] = {
str(key): str(value)
for key, value in body.machine_choices.items()
if str(value) in MACHINE_OPTION_CODES
}
try:
save_section(root, "estimation", values, replace_keys=tuple(values))
return JSONResponse(content={"status": "success", **values})
except Exception:
logger.exception("B09 산출 조건 저장 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "산출 조건을 저장하지 못했습니다."},
)
@router.get("/{project_id}/estimation/unit-prices/{code}")
async def get_unit_price_detail(project_id: UUID, code: str) -> JSONResponse:
"""일위대가 **본표** — 「무엇으로 이루어졌나」. 줄마다 원천·파고들기 표시가 붙는다."""
try:
return JSONResponse(content={"status": "success", **detail_of(cached_build(), code)})
return JSONResponse(
content={"status": "success", **detail_of(await _build_for(project_id), code)}
)
except PriceBookError as error:
return JSONResponse(status_code=404, content={"status": "error", "message": str(error)})
except Exception:
@@ -0,0 +1,603 @@
/* =============================================================================
* B09_Estimation_UI_BaseData.ts
* · ( 12 16 ).
*
* : 노무비목록표 · ·
* : 중기목록표 ( + ·· 3)
*
* ( 6 · 41)
* . · .
*
* (`B09_Estimation_UI_Page.ts`) 700 .
* ** ** .
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { API_BASE_URL } from "@config/config_frontend";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
/** 목록표 한 줄 — 실무 시트 칸 그대로. */
export interface BaseDataRow {
code: string;
name: string;
spec: string;
unit: string;
unit_price_krw: string | null;
note: string;
}
/** 중기목록표 한 줄 — 합계와 3분할을 함께 보인다. */
export interface MachineRow {
code: string;
name: string;
spec: string;
unit: string;
total_krw: string | null;
labor_krw: string | null;
material_krw: string | null;
expense_krw: string | null;
note: string;
}
export interface BaseDataDto {
status: string;
labor: BaseDataRow[];
material: BaseDataRow[];
expense: BaseDataRow[];
machine: MachineRow[];
}
export async function fetchBaseData(projectId: string): Promise<BaseDataDto> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/base-data`,
{ credentials: "include" },
);
if (!response.ok) throw new Error(`base-data ${response.status}`);
return (await response.json()) as BaseDataDto;
}
function money(value: string | null): string {
if (value === null || value === "") return "";
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed.toLocaleString("ko-KR") : value;
}
function head(text: string): HTMLElement {
const el = document.createElement("div");
el.className = "b09-hint";
el.style.fontWeight = "600";
el.textContent = text;
return el;
}
function note(text: string): HTMLElement {
const el = document.createElement("div");
el.className = "b09-hint";
el.textContent = text;
return el;
}
function table(headers: string[], rows: string[][], leftCols: number[]): HTMLElement {
const el = document.createElement("table");
el.className = "b09-sheet";
const thead = document.createElement("thead");
const headRow = document.createElement("tr");
headers.forEach((text, index) => {
const th = document.createElement("th");
th.textContent = text;
if (leftCols.includes(index)) th.className = "b09-left";
headRow.append(th);
});
thead.append(headRow);
const tbody = document.createElement("tbody");
for (const cells of rows) {
const tr = document.createElement("tr");
cells.forEach((text, index) => {
const td = document.createElement("td");
td.textContent = text;
if (leftCols.includes(index)) td.className = "b09-left";
tr.append(td);
});
tbody.append(tr);
}
el.append(thead, tbody);
return el;
}
/** 목록표 한 장 — 코드·명칭·규격·단위·단가·비고 (실무 시트와 같은 칸). */
function catalogTable(rows: BaseDataRow[]): HTMLElement {
return table(
["코드번호", "명 칭", "규 격", "단위", "단 가", "비 고"],
rows.map((row) => [
row.code,
row.name,
row.spec,
row.unit,
money(row.unit_price_krw),
row.note,
]),
[0, 1, 2, 5],
);
}
/**
* .
*
* ** ** .
* ( ).
*/
export function drawBaseDataTab(body: HTMLElement, data: BaseDataDto): void {
const groups: Array<[string, BaseDataRow[], string]> = [
["노무비목록표", data.labor, ""],
[
"재료비목록표",
data.material,
data.material.length <= 1
? "⚠ 사급 자재 카탈로그가 아직 서지 않아 줄이 거의 없습니다 — 자재값 출처(업체 견적·물가지)를 붙이면 채워집니다."
: "",
],
["경비목록표", data.expense, "기계 취득가격입니다(천원) — 시간당 사용료는 「중기」 탭입니다."],
];
for (const [title, rows, hint] of groups) {
body.append(head(`${title} (${rows.length})`));
if (hint) body.append(note(hint));
if (rows.length === 0) {
body.append(note("아직 선 줄이 없습니다 — 값을 0 으로 채우지 않습니다."));
continue;
}
body.append(catalogTable(rows));
}
}
/** 중기 탭 — 중기목록표. 합계와 3분할을 함께 보인다(실무 시트와 같은 칸). */
export function drawMachineTab(body: HTMLElement, data: BaseDataDto): void {
body.append(head(`중기목록표 (${data.machine.length})`));
if (data.machine.length === 0) {
body.append(note("아직 선 줄이 없습니다 — 값을 0 으로 채우지 않습니다."));
return;
}
body.append(
table(
["코드번호", "명 칭", "규 격", "단위", "합 계", "노 무 비", "재 료 비", "경 비", "비 고"],
data.machine.map((row) => [
row.code,
row.name,
row.spec,
row.unit,
money(row.total_krw),
money(row.labor_krw),
money(row.material_krw),
money(row.expense_krw),
row.note,
]),
[0, 1, 2, 8],
),
);
// ⚠ 계산 과정을 감추지 않는다(PLAN 8-13). 조종원 환산이 실무와 다른 것을 여기서 밝힌다.
body.append(
note(
"조종원 노임은 「노임 ÷ 8시간 × 16/12 × 25/20」(약 1.667배)으로 셉니다. " +
"공표 노임은 기본급여액뿐이라 제수당·상여금·퇴직급여충당금을 따로 계상해야 " +
"합니다(건협 임금적용요령 4-나 · 기재부 정부 입찰·계약 집행기준 제76조의3). " +
"⚠ 계수 자체의 예규 원문은 아직 못 봐 실무 관행을 따랐습니다 — 실무 두 공사지· " +
"임도교본 예제·상용 적산 프로그램이 모두 같은 계수를 씁니다.",
),
);
body.append(note("잡재료(주연료의 %)는 연료 소요량에 포함되어 있습니다 — 따로 세지 않습니다."));
}
/** 두 탭이 함께 쓰는 「아직 못 불러왔습니다」 문구. */
export function drawBaseDataError(body: HTMLElement): void {
body.append(note(L("B09_Estimation_Tab_Pending")));
}
/* =============================================================================
* (A9) · (A10) · .
*
* ** .** .
* ·
* **·** , .
* ========================================================================== */
/** 원천 한 칸 — 값이 없으면 **빈칸**이다. 0 을 넣으면 「0원짜리 견적」으로 읽힌다. */
export interface PriceSlot {
name: string;
price_krw: string | null;
/** 「페이지」 자리 — 물가지는 쪽수, 견적은 업체명·날짜(확정 ③). */
source_note: string;
adopted: boolean;
}
export interface MaterialComparisonRow {
code: string;
name: string;
spec: string;
unit: string;
slots: PriceSlot[];
adopted_slot: number;
adopted_price_krw: string | null;
note: string;
}
export interface FuelScope {
key: string;
label: string;
available: boolean;
why?: string;
}
export interface PriceSourcesDto {
status: string;
material_comparison: {
slot_names: string[];
rows: MaterialComparisonRow[];
notes: string[];
};
base_reference: {
exchange: { rows: unknown[]; note: string };
labor: {
rows: Array<{
code: string;
name: string;
day_wage_krw: string | null;
hourly_krw: string | null;
formula: string;
}>;
note: string;
};
fuel: {
diesel_krw_per_l: string | null;
scope: string;
effective_date: string;
dataset_id: string;
scopes: FuelScope[];
note: string;
};
};
}
export async function fetchPriceSources(projectId: string): Promise<PriceSourcesDto> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/price-sources`,
{ credentials: "include" },
);
if (!response.ok) throw new Error(`price-sources ${response.status}`);
return (await response.json()) as PriceSourcesDto;
}
/**
* **·** .
* .
*/
function comparisonTable(slotNames: string[], rows: MaterialComparisonRow[]): HTMLElement {
const el = document.createElement("table");
el.className = "b09-sheet";
const thead = document.createElement("thead");
const top = document.createElement("tr");
const bottom = document.createElement("tr");
// ⚠ 슬롯 6 이 곧 「적용 단가」다(`JUKNM=6`). 그 자리에 「적 용」 칸을 또 세우면
// 같은 값이 두 번 선다 — 실무 시트도 원천 다섯 + 적용 하나로 끝난다.
const appliedIsLastSlot =
rows.length > 0 && rows.every((row) => row.adopted_slot === slotNames.length);
const trailing = appliedIsLastSlot ? [] : ["적 용"];
const fixed = ["코드번호", "명 칭", "규 격", "단위"];
fixed.forEach((text, index) => {
const th = document.createElement("th");
th.textContent = text;
th.rowSpan = 2;
if (index <= 2) th.className = "b09-left";
top.append(th);
});
for (const name of [...slotNames, ...trailing]) {
const th = document.createElement("th");
th.textContent = name;
th.colSpan = 2;
top.append(th);
for (const sub of ["단 가", "페이지"]) {
const cell = document.createElement("th");
cell.textContent = sub;
bottom.append(cell);
}
}
const noteHead = document.createElement("th");
noteHead.textContent = "비 고";
noteHead.rowSpan = 2;
noteHead.className = "b09-left";
top.append(noteHead);
thead.append(top, bottom);
const tbody = document.createElement("tbody");
for (const row of rows) {
const tr = document.createElement("tr");
const put = (text: string, left = false): void => {
const td = document.createElement("td");
td.textContent = text;
if (left) td.className = "b09-left";
tr.append(td);
};
put(row.code, true);
put(row.name, true);
put(row.spec, true);
put(row.unit);
for (const slot of row.slots) {
const td = document.createElement("td");
td.textContent = money(slot.price_krw);
// 채택한 원천을 굵게 — 「어느 값을 썼나」를 표가 스스로 밝힌다.
if (slot.adopted) td.style.fontWeight = "700";
tr.append(td);
const page = document.createElement("td");
page.textContent = slot.source_note;
page.className = "b09-left";
tr.append(page);
}
if (!appliedIsLastSlot) {
put(money(row.adopted_price_krw));
put(row.adopted_slot ? (row.slots[row.adopted_slot - 1]?.name ?? "") : "", true);
}
put(row.note, true);
tbody.append(tr);
}
el.append(thead, tbody);
return el;
}
/** 환율및기초자료 — 실무 시트 세 구획(환율·인건비·단가 및 재료비)을 차례대로. */
function baseReferenceSections(body: HTMLElement, data: PriceSourcesDto["base_reference"]): void {
body.append(head("환율및기초자료 — ① 환율"));
body.append(note(data.exchange.note));
body.append(head(`환율및기초자료 — ② 인건비 (${data.labor.rows.length})`));
if (data.labor.rows.length === 0) {
body.append(note("아직 선 줄이 없습니다 — 값을 0 으로 채우지 않습니다."));
} else {
body.append(
table(
["코드번호", "직 종", "일 당", "시간당", "산 식"],
data.labor.rows.map((row) => [
row.code,
row.name,
money(row.day_wage_krw),
money(row.hourly_krw),
row.formula,
]),
[0, 1, 4],
),
);
}
// 시간당이 소수로 남는 까닭을 밝힌다 — 안 밝히면 「덜 다듬은 값」으로 읽힌다.
body.append(
note(
"시간당은 나눈 값을 그대로 둡니다 — 여기서 원 단위로 자르면 기계 시간당 사용료가 " +
"조금씩 어긋납니다. 자르는 자리는 일위대가·내역서 쪽입니다.",
),
);
body.append(note(data.labor.note));
body.append(head("환율및기초자료 — ③ 단가 및 재료비"));
const fuel = data.fuel;
body.append(
table(
["항 목", "단 가", "적용 범위", "기준일", "자료"],
[
[
"경유",
money(fuel.diesel_krw_per_l),
fuel.scopes.find((scope) => scope.key === fuel.scope)?.label || fuel.scope,
fuel.effective_date,
fuel.dataset_id,
],
],
[0, 2, 3, 4],
),
);
// ⚠ 확정 ⑮ — 전국/지역을 고르는 칸. 자료가 없는 것은 **고를 수 없게** 두고
// 까닭을 곧바로 밝힌다. 고르게만 해 두고 값이 없으면 조용히 틀린 값이 선다.
const picker = document.createElement("div");
picker.className = "b09-hint";
picker.style.display = "flex";
picker.style.alignItems = "center";
picker.style.gap = "8px";
const label = document.createElement("span");
label.textContent = "유가 적용 범위";
const select = document.createElement("select");
for (const scope of fuel.scopes) {
const option = document.createElement("option");
option.value = scope.key;
option.textContent = scope.available ? scope.label : `${scope.label} (자료 없음)`;
option.disabled = !scope.available;
option.selected = scope.key === fuel.scope;
select.append(option);
}
picker.append(label, select);
body.append(picker);
for (const scope of fuel.scopes) {
if (!scope.available && scope.why) body.append(note(`${scope.label}: ${scope.why}`));
}
body.append(note(fuel.note));
}
/** 기초자료 탭 아래쪽 — A9·A10 두 장. */
export function drawPriceSourcesSections(body: HTMLElement, data: PriceSourcesDto): void {
const comparison = data.material_comparison;
body.append(head(`자재단가대비표 (${comparison.rows.length})`));
if (comparison.rows.length <= 1) {
body.append(
note(
"⚠ 사급 자재 카탈로그가 아직 서지 않아 줄이 거의 없습니다 — 재료비목록표와 같은 원인입니다.",
),
);
}
if (comparison.rows.length === 0) {
body.append(note("아직 선 줄이 없습니다 — 값을 0 으로 채우지 않습니다."));
} else {
body.append(comparisonTable(comparison.slot_names, comparison.rows));
}
for (const text of comparison.notes) body.append(note(text));
baseReferenceSections(body, data.base_reference);
}
/** 두 표를 아직 못 받아왔을 때 — 화면을 비우지 않는다. */
export function drawPriceSourcesPending(body: HTMLElement): void {
body.append(note("자재단가대비표·환율및기초자료를 불러오는 중입니다…"));
}
/* =============================================================================
* · ( , 2026-09-09)
*
* ·
* . ** **,
* .
* ========================================================================== */
export interface FactorOption {
key: string;
value?: string;
label: string;
note?: string;
}
export interface RangeFactorRow {
key: string;
work_item_code: string;
work_item_name: string;
factor: string;
raw_cell: string;
chosen: string;
value: string;
is_default: boolean;
options: FactorOption[];
basis: string[];
}
export interface MachineChoiceRow {
work_item_code: string;
work_item_name: string;
chosen: string;
default: string;
is_default: boolean;
source: string;
options: FactorOption[];
basis: string[];
}
export interface FactorChoicesDto {
status: string;
ranges: RangeFactorRow[];
machines: MachineChoiceRow[];
notes: string[];
}
export async function fetchFactorChoices(projectId: string): Promise<FactorChoicesDto> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/factors`,
{ credentials: "include" },
);
if (!response.ok) throw new Error(`factors ${response.status}`);
return (await response.json()) as FactorChoicesDto;
}
export async function saveFactorChoices(
projectId: string,
body: { range_factor_choices?: Record<string, string>; machine_choices?: Record<string, string> },
): Promise<void> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/factors`,
{
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!response.ok) throw new Error(`factors save ${response.status}`);
}
function picker(
label: string,
options: FactorOption[],
chosen: string,
onPick: (key: string) => void,
): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b09-hint";
wrap.style.display = "flex";
wrap.style.alignItems = "center";
wrap.style.gap = "8px";
wrap.style.flexWrap = "wrap";
const name = document.createElement("span");
name.style.fontWeight = "600";
name.textContent = label;
const select = document.createElement("select");
for (const option of options) {
const item = document.createElement("option");
item.value = option.key;
item.textContent = option.label;
item.selected = option.key === chosen;
select.append(item);
}
select.addEventListener("change", () => onPick(select.value));
wrap.append(name, select);
return wrap;
}
/**
* .
*
* ** **
* ( ).
*/
export function drawFactorChoices(
body: HTMLElement,
data: FactorChoicesDto,
projectId: string,
reload: () => void,
): void {
body.append(head("산출 조건 — 품셈이 한 값으로 안 준 자리"));
for (const row of data.ranges) {
const title = `${row.work_item_name} 작업효율(${row.factor})`;
body.append(
picker(title, row.options, row.chosen, (key) => {
void saveFactorChoices(projectId, { range_factor_choices: { [row.key]: key } }).then(
reload,
);
}),
);
body.append(
note(
`품셈 원문은 「${row.raw_cell}」 — 지금 쓰는 값 ${row.value}` +
(row.is_default ? " (기본값으로 돌고 있습니다)" : " (사용자가 고른 값입니다)"),
),
);
for (const line of row.basis) body.append(note(line));
}
for (const row of data.machines) {
body.append(
picker(`${row.work_item_name} 장비 규격`, row.options, row.chosen, (key) => {
void saveFactorChoices(projectId, {
machine_choices: { [row.work_item_code]: key },
}).then(reload);
}),
);
body.append(
note(
row.source === "note"
? "⚠ 이 장비는 품셈 표가 아니라 [주] 에 적혀 있어 공종 마스터가 아직 못 싣는 값입니다 — 이 칸이 그 자리를 대신합니다."
: "품셈 표가 정한 장비입니다." +
(row.is_default ? "" : " ⚠ 지금은 사용자가 바꾼 값으로 돌고 있습니다."),
),
);
for (const line of row.basis) body.append(note(line));
}
for (const line of data.notes) body.append(note(line));
}

Some files were not shown because too many files have changed in this diff Show More