chore: 네 창 작업 269건 main 반영 (2026-09-09) (#13)
This commit was merged in pull request #13.
This commit is contained in:
+7
-1
@@ -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*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 − (top−bottom)·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]
|
||||
|
||||
@@ -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). 세트는 폭의
|
||||
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 []),
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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 {},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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+n²)) → 평균두께 → 입적 → 돌 · 막자갈 · 고임돌 →
|
||||
터파기(기초 + 비탈) → 되메우기 → 잔토
|
||||
|
||||
⇒ **기슭막이는 새 식이 아니라 「형태가 돌쌓기면 돌쌓기 식」**이다. 여기서 식을 다시 짜면
|
||||
같은 계산이 두 벌이 되어 돌쌓기와 갈린다(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(H−1)
|
||||
#: 밑수 **돌쌓기 + 돌붙임** ↔ 기슭막이 돌쌓기만
|
||||
#: 단위 **개소당** ↔ 기슭막이 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+n²) 를 곱하지 않는다.
|
||||
"""
|
||||
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
|
||||
@@ -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)
|
||||
|
||||
@@ -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); }
|
||||
`;
|
||||
|
||||
/** 스타일을 한 번만 넣는다 — 페이지를 다시 그려도 중복되지 않는다. */
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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.55∼0.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.55∼0.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.55∼0.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
|
||||
@@ -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} 가 "
|
||||
|
||||
@@ -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
|
||||
@@ -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호 「유류가격은 해당 지역의 가격」 — 지역 공시가를 고를 수 있게 "
|
||||
"칸을 두었으나 아직 전국 공시가만 받아 와 있습니다."
|
||||
),
|
||||
},
|
||||
}
|
||||
@@ -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.55∼0.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 육상토사(0~1m) 장비(90%) 유압식백호우 | k 0.9 | f 0.77 | E 0.60 | ㎝ 20(135°)
|
||||
9-13-2 육상토사(1~2m) 장비(90%) 유압식백호우 | **「육상토사(0~1m)와 동일」**
|
||||
9-13-10 용수 암절취(0~1m) 들어내기 … | k 0.55 「**육상과동일**」
|
||||
|
||||
그 자리를 안 따라가면 **장비 몫 90%가 통째로 안 붙고 인력 10%만 선다** — 2026-09-09
|
||||
실측으로 구조물터파기 여덟 갈래가 전부 그 모양이었다(「단가가 일부만 섰습니다 — 붙은 몫 10%」).
|
||||
|
||||
⚠ **값을 옮겨 적지 않는다.** 가리키는 절의 계수를 **그때그때 읽어** 쓴다. 옮겨 적으면
|
||||
품셈이 개정될 때 한쪽만 고쳐진다.
|
||||
|
||||
⚠ **어디서 온 값인지 남긴다.** 화면이 「9-13-1 과 동일(품셈 원문)」을 그대로 보여야
|
||||
나중에 누가 봐도 근거를 되짚을 수 있다(오늘 규칙).
|
||||
|
||||
⚠ **못 따라가는 참조는 따라간 척하지 않는다.**
|
||||
· **자기 자신을 가리키는 것** — 9-13-14 가 「육상 발파암(1~2m)와 동일」이라 적었는데
|
||||
그 절이 곧 육상 발파암(1~2m)이다(원문 오기로 보이나 **고쳐 읽지 않는다**).
|
||||
· **가리키는 절을 못 찾는 것 · 그 절도 계수가 없는 것.**
|
||||
이 셋은 사유를 남기고 **빈 채로 둔다.**
|
||||
"""
|
||||
|
||||
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 은 「치즐소모량 … 육상과동일」과 「들어내기 유압식백호우 …
|
||||
용수 암절취(0~1m)와 동일」을 함께 적는다. 아무 줄에서나 주우면 **용수 자리에 육상
|
||||
계수**가 붙어 작업효율이 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
|
||||
@@ -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
Reference in New Issue
Block a user