Merge remote-tracking branch 'origin/main_desktop_1' into sub_desktop_1

This commit is contained in:
2026-09-08 06:49:48 +09:00
8 changed files with 460 additions and 8 deletions
+11 -2
View File
@@ -643,13 +643,22 @@ def _structure_rows(
elif entry.get("class_from") in ("back_length", "bond") and class_key is None:
unmatched.append(f"{wording_type_label(type_id)}{class_basis}")
length = float(structure.get("length_m") or 0.0)
# ⚠ 관측 원단위가 「개소당」·「㎡당」인 종류는 **연장으로 세면 축이 어긋난다** —
# 집수정 한 개소가 연장 2m 면 값이 두 배로 실린다(2026-09-08 ㉕ 실증).
# 성분은 개소 기준으로 맞게 서는데 **줄의 축만** 틀렸던 자리다.
bill_unit = str(structure.get("billing_unit") or "") or "m"
bill_quantity = (
float(structure.get("billing_quantity") or 0.0)
if structure.get("billing_unit")
else length
)
rows.append(
{
"work_item_code": code,
"name": str(structure.get("name") or type_id),
"spec": _spec_detail(structure),
"unit": "m",
"quantity": length,
"unit": bill_unit,
"quantity": bill_quantity,
"quantity_gross": None,
"application_ratio_pct": None,
"application_ratio_breakdown": None,
@@ -131,6 +131,26 @@ def scale_for(entry: dict[str, Any], structure: dict[str, Any]) -> tuple[float,
return 1.0, "1 개소"
def billing_of(
type_id: str,
spec: dict[str, Any],
structure: dict[str, Any],
table: ObservedUnitTable | None = None,
) -> tuple[str, float] | None:
"""(내역 단위, 그 단위로 센 수량). 표에 없으면 `None`.
⚠ **왜 있나** — 관측 원단위는 「개소당」·「㎡당」으로도 온다. 그런데 인계 줄이 늘
「m · 연장」으로 나가고 있어, **집수정 한 개소가 「연장 2m」면 값이 두 배로 실렸다**
(2026-09-08 ㉕ 실증에서 드러남). 성분은 개소 기준으로 맞게 서는데 **줄의 축만
어긋나** 있어서 아무 시험도 안 잡았다.
"""
found = (table or load_observed_table()).find(type_id, spec)
if found is None:
return None
scale, _note = scale_for(found, structure)
return str(found.get("unit") or "개소"), scale
def expand_observed(
type_id: str,
spec: dict[str, Any],
@@ -38,6 +38,7 @@ from B08_Quantity.B08_Quantity_Engine_ObservedUnit import (
BASIS_DERIVED,
BASIS_OBSERVED,
ObservedUnitTable,
billing_of,
expand_observed,
load_observed_table,
)
@@ -139,6 +140,11 @@ class StructureQuantity:
options: dict[str, Any] = field(default_factory=dict)
components: list[Component] = field(default_factory=list)
notes: list[str] = field(default_factory=list)
#: 내역 줄이 설 **단위와 그 단위로 센 수량**. 관측 원단위가 「개소당」·「㎡당」인
#: 종류는 연장(m)으로 세면 축이 어긋난다(2026-09-08 ㉕ 실증).
#: 비어 있으면 종전대로 「m · 연장」으로 선다.
billing_unit: str = ""
billing_quantity: float = 0.0
#: ⚠ **저장 제원의 실제 칸 이름**은 `back_len_cm` 이다(레지스트리 확인).
@@ -499,6 +505,22 @@ def _observed_components(
return expand_observed(type_id, spec, structure, observed)
def _observed_billing(
type_id: str,
structure: dict[str, Any],
observed: ObservedUnitTable | None,
) -> tuple[str, float] | None:
"""관측표가 정한 **내역 단위와 개수**. 규격 키가 없는 종류는 건드리지 않는다."""
keys = OBSERVED_SPEC_KEYS.get(type_id)
if keys is None:
return None
options = structure.get("options") or {}
spec = {key: options[key] for key in keys if options.get(key) is not None}
if not spec:
return None
return billing_of(type_id, spec, structure, observed)
def expand(
structure: dict[str, Any],
names: dict[str, str] | None = None,
@@ -538,6 +560,9 @@ def expand(
if components or notes:
result.components = [Component(**item) for item in components]
result.notes.extend(notes)
billing = _observed_billing(type_id, structure, observed)
if billing is not None:
result.billing_unit, result.billing_quantity = billing
return result
from B08_Quantity.B08_Quantity_Wording import type_label
@@ -601,6 +626,9 @@ def build_table(
"height_m": item.height_m,
"start_m": item.start_m,
"end_m": item.end_m,
# 내역 줄이 설 단위·수량 — 관측 원단위가 「개소당」인 종류는 연장으로 못 센다.
"billing_unit": item.billing_unit,
"billing_quantity": item.billing_quantity,
# 저장된 제원 — 형식(반중력식…)처럼 **뒤 단계가 읽어야 하는** 값이 여기 있다.
"options": item.options,
"notes": item.notes,
+102 -5
View File
@@ -200,6 +200,17 @@ function selectField(
return row;
}
/** 지반 종류 표기 — 서버 키가 화면에 새지 않게. 모르는 키는 그대로 보인다. */
const GROUND_LABELS: Record<string, string> = {
soil: "토사",
ripping_rock: "리핑암",
blasting_rock: "발파암",
};
function groundLabel(kind: string): string {
return GROUND_LABELS[kind] ?? kind;
}
/** 자재 한 줄의 관급/사급. `install_by` 는 **관급 줄에만** 뜻이 있다. */
export interface SupplyChoice {
supply: string;
@@ -222,6 +233,72 @@ interface DraftSettings {
dirty: boolean;
}
/** + .
*
* ** .**
* .
* ** .** .
*/
function devUnlockRow(projectId: string, reload: () => void): HTMLElement {
const row = document.createElement("div");
row.className = "b08-quantity__dev";
const title = document.createElement("p");
title.className = "b08-quantity__note";
title.textContent = L("B08_Quantity_Dev_Title");
row.append(title);
const state = document.createElement("p");
state.className = "b08-quantity__note";
row.append(state);
const paint = (): void => {
fetch(`/api/projects/${projectId}/dev/unlock`, { credentials: "include" })
.then((response) => (response.ok ? response.json() : null))
.then((body: { bypassed_stages?: number[] } | null) => {
const stages = body?.bypassed_stages ?? [];
state.textContent = stages.length
? `${L("B08_Quantity_Dev_Bypassed")} (${stages.join(", ")})`
: L("B08_Quantity_Dev_Normal");
})
.catch(() => {
state.textContent = L("B08_Quantity_Dev_Normal");
});
};
paint();
const call = (method: "POST" | "DELETE", button: HTMLButtonElement): void => {
button.disabled = true;
fetch(`/api/projects/${projectId}/dev/unlock`, { method, credentials: "include" })
.then((response) => {
if (!response.ok) throw new Error(String(response.status));
showToast(L("B08_Quantity_Dev_Done"), "success");
paint();
reload();
})
.catch(() => showToast(L("B08_Quantity_Dev_Failed"), "error"))
.finally(() => {
button.disabled = false;
});
};
const unlock = createButton({
label: L("B08_Quantity_Dev_Unlock"),
variant: "ghost",
onClick: () => call("POST", unlock),
});
const relock = createButton({
label: L("B08_Quantity_Dev_Relock"),
variant: "ghost",
onClick: () => call("DELETE", relock),
});
const buttons = document.createElement("div");
buttons.className = "b08-quantity__actions ui-sidebar-actions";
buttons.append(unlock, relock);
row.append(buttons);
return row;
}
/** : + []·[] .
* `reload` · . */
function buildQuantitySidePanel(
@@ -233,13 +310,28 @@ function buildQuantitySidePanel(
const panel = document.createElement("div");
panel.className = "b08-quantity__panel";
// ── 개발 전용 「확정 없이 다음으로」 ────────────────────────────────────────
// ⚠ **맨 위에 둔다.** 패널이 `overflow: hidden` 이라 아래에 붙이면 화면 밖으로
// 밀려 **눌리지 않는다**(2026-09-08 화면에서 실제로 그랬다 — 단추 y=772,
// 패널 높이 740). 상태 경고이기도 하니 자리도 여기가 맞다.
// ⚠ **이것은 보조 문일 뿐이다.** 진짜 문은 서버가 `ENVIRONMENT` 로 막는다
// (`common_util_dev_unlock`). 화면만 숨기면 API 는 그대로 뚫려 있다.
// ⚠ **계산을 대신 돌리지 않는다** — 워크플로 잠금만 푼다. 값이 비어 보이는 것은
// 정상이고, 그것을 「미확보」로 보이는 것이 이 화면이 이미 하는 일이다.
if (import.meta.env.DEV && projectId) {
panel.append(devUnlockRow(projectId, reload));
}
// 계수는 서버 상수가 유일한 정의처다 — 화면은 보여 주기만 하고 값을 다시 적지 않는다.
panel.append(field(L("B08_Quantity_Side_Method"), L("B08_Quantity_Side_Method_Value")));
const entries = Object.entries(table?.conversion_factors ?? {});
if (entries.length) {
panel.append(field(L("B08_Quantity_Side_Factors"), ""));
for (const [kind, value] of entries) {
panel.append(field(kind, String((value as { compacted: number }).compacted)));
// ⚠ 서버 키(`soil`·`ripping_rock`·`blasting_rock`)를 그대로 내보내지 않는다 —
// 2026-09-08 ㉕ 화면 통과에서 좌측 세 줄이 개발자 키로 떠 있었다(`soil_guard` 와 같은 병).
// 모르는 키는 **지어내지 않고** 그대로 보인다.
panel.append(field(groundLabel(kind), String((value as { compacted: number }).compacted)));
}
}
@@ -355,10 +447,15 @@ function buildQuantitySidePanel(
// 2026-09-08 ㉙: 인계가 **타설 공종 줄을 실제로 세운다.** 겹치지 않는 것이 확인됐다 —
// 품셈 12-1-1 표는 직종·품만 주고 재료를 안 줘서, 품은 이 줄 · 재료는 자재 쪽이다.
// ⚠ 돌쌓기 뒤채움(채움콘크리트)은 뺐다 — 그 공종 품에 이미 들어 있을 수 있다.
const pending = document.createElement("p");
pending.className = "b08-quantity__note";
pending.textContent = L("B08_Quantity_Placing_NotApplied");
panel.append(pending);
// ⚠ **기본값일 때는 싣지 않는다** — 바로 아래 「기본값으로 계산 중」 경고가 더 많은 것을
// 말하는데, 둘을 겹쳐 실으면 긴 문구가 그 경고를 화면 밖으로 밀어낸다
// (2026-09-08 ㉕ 화면 통과에서 실제로 잘려 있었다).
if (!placing.is_default) {
const applied = document.createElement("p");
applied.className = "b08-quantity__note";
applied.textContent = L("B08_Quantity_Placing_NotApplied");
panel.append(applied);
}
}
// ⚠ 「정하면 얼마나 달라지는지」까지 보여야 사용자가 판단한다. 이 값은 **참고 표시 전용**이고
// B08 의 어떤 계산에도 안 들어간다(금액은 B09 몫).
+165
View File
@@ -0,0 +1,165 @@
"""개발환경 전용 — **확정을 거치지 않고 다음 단계로 넘어가게** 하는 자리.
있나
프로그램은 단계마다 [확정] 해야 다음 페이지가 열린다. 그래서 **상세 설계를 확정하기
전에는 B08·B09 아예 없다.** 화면 검증을 하려면 매번 남의 프로젝트 확정 상태에
매달려야 했다(2026-09-08 실제로 자리에서 창이 막혔다).
**계산을 대신 돌리지 않는다 잠금만 푼다.**
[확정] ** 측점을 다시 계산해 정본에 쓰는 **이다. 여기서 계산을 흉내 내면
확정 했는데 확정된 생겨 **막힌 것보다 나쁘다.** 그래서 모듈이 바꾸는
것은 `project_workflow_stages.state` ** 칸뿐**이다. 값이 없으면 B08
미확보 뜨는 것이 **정상이고 그것이 옳은 화면**이다.
**문은 서버가 정본이다.**
화면에서 단추를 숨기는 것만으로는 API 그대로 뚫려 있다. 그래서 모듈이
`ENVIRONMENT` 보고 **운영에서는 아예 거절한다.** 화면 `import.meta.env.DEV`
보조일 뿐이다.
**되돌릴 있어야 한다.**
단계와 **이전 상태** `dev_unlock` 칸에 적어 두고, 되돌리기가 그대로 복원한다.
그러면 검증용 프로젝트가 이상한 상태로 굳는다.
"""
from __future__ import annotations
import json
from datetime import datetime
from typing import Any
import aiomysql
from common_util.common_util_workflow_state import STAGE_KEYS
from config.config_system import ENVIRONMENT
#: 개발환경으로 보는 값. 그 밖(staging·production)에서는 이 기능이 아예 안 돈다.
DEV_ENVIRONMENTS = frozenset({"development", "dev", "local", "test"})
#: 되돌리기용 기록을 남기는 자리. 프로젝트 저장 폴더가 아니라 **DB 안**에 둔다 —
#: 상태와 같은 곳에 있어야 둘이 어긋나지 않는다.
UNLOCK_MESSAGE_PREFIX = "DEV_UNLOCK:"
class DevUnlockDisabled(RuntimeError):
"""개발환경이 아니어서 거절함. **이 예외가 곧 운영 쪽 문**이다."""
def is_dev_environment() -> bool:
"""지금 환경에서 이 기능을 켜도 되는가."""
return str(ENVIRONMENT or "").strip().lower() in DEV_ENVIRONMENTS
def require_dev_environment() -> None:
"""개발환경이 아니면 **여기서 멈춘다.** 화면이 아니라 서버가 막는 자리다."""
if not is_dev_environment():
raise DevUnlockDisabled(f"개발환경에서만 쓸 수 있습니다 (지금 환경: {ENVIRONMENT}).")
async def unlock_stages(
cursor: aiomysql.DictCursor, project_id: str, up_to_stage: int
) -> dict[str, Any]:
"""`up_to_stage` 까지의 단계를 **상태만** COMPLETE 로 만든다.
계산·저장은 하지 않는다. 되돌릴 있게 **이전 상태를 함께 적어 둔다.**
이미 COMPLETE 단계는 건드리지 않는다 되돌릴 남의 확정까지 풀면 된다.
"""
require_dev_environment()
if not 0 <= up_to_stage < len(STAGE_KEYS):
raise ValueError(f"단계 번호가 범위를 벗어났습니다: {up_to_stage}")
await cursor.execute(
"""
SELECT stage_no, state, message
FROM project_workflow_stages
WHERE project_id = %s AND stage_no <= %s
ORDER BY stage_no ASC
""",
(project_id, up_to_stage),
)
rows = await cursor.fetchall()
changed: list[dict[str, Any]] = []
now = datetime.utcnow()
for row in rows:
if row["state"] == "COMPLETE":
continue # 진짜로 확정된 단계 — 손대지 않는다.
changed.append({"stage_no": int(row["stage_no"]), "state": row["state"]})
note = f"{UNLOCK_MESSAGE_PREFIX}{row['state']}"
await cursor.execute(
"""
UPDATE project_workflow_stages
SET state = 'COMPLETE',
progress_percent = 100,
completed_at = %s,
message = %s
WHERE project_id = %s AND stage_no = %s
""",
(now, note, project_id, int(row["stage_no"])),
)
return {
"unlocked": changed,
"up_to_stage": up_to_stage,
"note": (
"확정을 건너뛰고 잠금만 풀었습니다 — 계산은 돌지 않았습니다. "
"값이 비어 보이는 것은 정상입니다."
),
}
async def relock_stages(cursor: aiomysql.DictCursor, project_id: str) -> dict[str, Any]:
"""우회로 푼 단계를 **원래 상태로 되돌린다.**
`DEV_UNLOCK:` 표시가 붙은 단계만 되돌린다 표시가 없으면 **진짜 확정**이라
건드리면 된다. 표시 뒤에 적어 상태를 그대로 복원한다.
"""
require_dev_environment()
await cursor.execute(
"""
SELECT stage_no, message
FROM project_workflow_stages
WHERE project_id = %s AND message LIKE %s
ORDER BY stage_no ASC
""",
(project_id, f"{UNLOCK_MESSAGE_PREFIX}%"),
)
rows = await cursor.fetchall()
restored: list[dict[str, Any]] = []
for row in rows:
previous = str(row["message"])[len(UNLOCK_MESSAGE_PREFIX) :].strip() or "NOT_STARTED"
restored.append({"stage_no": int(row["stage_no"]), "state": previous})
await cursor.execute(
"""
UPDATE project_workflow_stages
SET state = %s,
progress_percent = 0,
completed_at = NULL,
message = NULL
WHERE project_id = %s AND stage_no = %s
""",
(previous, project_id, int(row["stage_no"])),
)
return {"relocked": restored}
async def unlock_status(cursor: aiomysql.DictCursor, project_id: str) -> dict[str, Any]:
"""지금 우회로 열려 있는 단계 목록. **화면이 띄울 안내의 근거**다."""
await cursor.execute(
"""
SELECT stage_no, message
FROM project_workflow_stages
WHERE project_id = %s AND message LIKE %s
ORDER BY stage_no ASC
""",
(project_id, f"{UNLOCK_MESSAGE_PREFIX}%"),
)
rows = await cursor.fetchall()
return {
"dev_environment": is_dev_environment(),
"bypassed_stages": [int(row["stage_no"]) for row in rows],
}
def as_json(payload: dict[str, Any]) -> str:
"""로그용 — 한글이 깨지지 않게."""
return json.dumps(payload, ensure_ascii=False)
@@ -0,0 +1,113 @@
"""개발환경 전용 — 「확정 없이 다음으로」 API.
**문은 여기가 정본이다.** 화면에서 단추를 숨겨도 API 열려 있으면 아무 소용이 없다.
그래서 입구 모두 `require_dev_environment()` 먼저 부르고, 운영에서는 **403** 으로
거절한다. 프론트의 `import.meta.env.DEV` 보조일 뿐이다.
**계산을 대신 돌리지 않는다.** 여기서 바뀌는 것은 `project_workflow_stages.state`
칸뿐이다. 자세한 까닭은 `common_util_dev_unlock` 머리말을 .
"""
from __future__ import annotations
import logging
from typing import Any
from uuid import UUID
import aiomysql
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from common_util.common_util_dev_unlock import (
DevUnlockDisabled,
relock_stages,
unlock_stages,
unlock_status,
)
from config.config_db import run_with_connection
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["DEV Unlock"])
#: 기본은 B08(수량산출)까지 — 검증이 가장 자주 막히던 자리다.
DEFAULT_UP_TO_STAGE = 5
class UnlockBody(BaseModel):
"""어디까지 열 것인가. 안 주면 B08 까지."""
up_to_stage: int = DEFAULT_UP_TO_STAGE
async def _with_cursor(connection: aiomysql.Connection, call: Any, *args: Any) -> Any:
"""쓰기라 **한 커넥션·한 트랜잭션**으로 묶는다(`run_with_connection` 주석 참조)."""
async with connection.cursor(aiomysql.DictCursor) as cursor:
result = await call(cursor, *args)
await connection.commit()
return result
@router.get("/{project_id}/dev/unlock")
async def get_unlock_status(project_id: UUID) -> JSONResponse:
"""지금 우회로 열려 있는 단계. **화면 안내의 근거**다."""
async def call(connection: aiomysql.Connection) -> dict[str, Any]:
async with connection.cursor(aiomysql.DictCursor) as cursor:
return await unlock_status(cursor, str(project_id))
try:
payload = await run_with_connection(call)
except Exception:
logger.exception("개발 우회 상태 조회 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "우회 상태를 읽지 못했습니다."},
)
return JSONResponse(content={"status": "success", **payload})
@router.post("/{project_id}/dev/unlock")
async def post_unlock(project_id: UUID, body: UnlockBody | None = None) -> JSONResponse:
"""확정을 건너뛰고 **잠금만** 푼다. 개발환경이 아니면 403."""
up_to = (body or UnlockBody()).up_to_stage
async def call(connection: aiomysql.Connection) -> dict[str, Any]:
return await _with_cursor(connection, unlock_stages, str(project_id), up_to)
try:
payload = await run_with_connection(call)
except DevUnlockDisabled as error:
return JSONResponse(status_code=403, content={"status": "error", "message": str(error)})
except ValueError as error:
return JSONResponse(status_code=400, content={"status": "error", "message": str(error)})
except Exception:
logger.exception("개발 우회 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "단계를 열지 못했습니다."},
)
logger.info("DEV 우회: project_id=%s 까지=%s", project_id, up_to)
return JSONResponse(content={"status": "success", **payload})
@router.delete("/{project_id}/dev/unlock")
async def delete_unlock(project_id: UUID) -> JSONResponse:
"""우회로 연 단계를 **원래 상태로 되돌린다.** 진짜 확정은 안 건드린다."""
async def call(connection: aiomysql.Connection) -> dict[str, Any]:
return await _with_cursor(connection, relock_stages, str(project_id))
try:
payload = await run_with_connection(call)
except DevUnlockDisabled as error:
return JSONResponse(status_code=403, content={"status": "error", "message": str(error)})
except Exception:
logger.exception("개발 우회 되돌리기 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "단계를 되돌리지 못했습니다."},
)
logger.info("DEV 우회 되돌림: project_id=%s", project_id)
return JSONResponse(content={"status": "success", **payload})
+7
View File
@@ -63,6 +63,10 @@ from B08_Quantity.B08_Quantity_Router_Earthwork import router as b08_earthwork_r
from B08_Quantity.B08_Quantity_Router_Material import router as b08_material_router
from B09_Estimation.B09_Estimation_Router import router as b09_estimation_router
from common_util.common_util_audit import note_api_call, record_call_burst
# 개발환경 전용 — 「확정 없이 다음으로」. **문은 서버가 정본이다** — `ENVIRONMENT` 가
# 개발이 아니면 세 입구 모두 403 으로 거절한다(화면 단추 숨김은 보조).
from common_util.common_util_dev_unlock_router import router as dev_unlock_router
from common_util.common_util_auth import (
require_company,
require_project_access,
@@ -541,6 +545,9 @@ app.include_router(b08_quantity_router, dependencies=protected_with_company)
app.include_router(b08_earthwork_router, dependencies=protected_with_company)
app.include_router(b08_material_router, dependencies=protected_with_company)
app.include_router(b09_estimation_router, dependencies=protected_with_company)
# 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근).
# 그 위에 서버가 환경까지 한 번 더 본다.
app.include_router(dev_unlock_router, dependencies=protected_with_company)
# ─────────────────────────────────────────────────────────────────────────
+14 -1
View File
@@ -629,9 +629,22 @@ export const ui_locales_b2 = {
B08_Quantity_Method_Ripping: ["긁어내기(암절취)", "Ripping"],
B08_Quantity_Method_Blasting: ["터뜨리기(발파암)", "Blasting"],
B08_Quantity_Placing_NotApplied: [
"타설 방식에 따라 이 공종의 단가가 달라집니다 — 콘크리트 물량이 「콘크리트 타설」 공종으로 견적에 넘어갑니다.",
"콘크리트 물량이 「콘크리트 타설」 공종으로 견적에 넘어갑니다.",
"The placing method changes this work item's unit price — concrete volume is handed over as a placing work item.",
],
B08_Quantity_Dev_Title: [
"⚠ 개발 전용 — 확정을 건너뛰고 다음 단계를 엽니다(계산은 돌지 않습니다).",
"⚠ Dev only — unlocks the next stage without confirming (no recalculation).",
],
B08_Quantity_Dev_Unlock: ["확정 없이 다음으로", "Skip confirm"],
B08_Quantity_Dev_Relock: ["원래대로 되돌리기", "Undo skip"],
B08_Quantity_Dev_Bypassed: [
"지금 확정을 건너뛴 상태입니다 — 값이 비어 보이는 것은 정상입니다. 건너뛴 단계",
"Currently skipping confirmation — empty values are expected. Skipped stages",
],
B08_Quantity_Dev_Normal: ["건너뛴 단계 없음 (정상 상태)", "No skipped stages"],
B08_Quantity_Dev_Done: ["단계 잠금을 바꿨습니다.", "Stage lock updated."],
B08_Quantity_Dev_Failed: ["단계 잠금을 바꾸지 못했습니다.", "Failed to update stage lock."],
B08_Quantity_Side_Topsoil: ["표토제거", "Topsoil Removal"],
B08_Quantity_Side_Topsoil_Label: ["표토 두께(m)", "Topsoil thickness (m)"],
B08_Quantity_Unset_Placeholder: ["안 정함", "Not set"],