feat(B08): 화면에 안 보이던 확정 대기 셋을 드러냄

목록에만 있고 화면에 없으면 사용자는 그것이 잠정인 줄도 모름. 셋 다
「지금 어떤 값으로 돌고 있는지 + 왜 잠정인지」를 함께 보임.

- 콘크리트 타설 방식을 좌측 패널 칸으로 냄. ⚠ 금액에 바로 걸리는 값이라
  기본값으로 돌고 있으면 「기본값 「레디믹스트」로 계산 중 — 아직 안 정한 값」
  안내를 띄움. 설정 기본을 None 으로 바꿔 「안 정함」과 「일부러 레디믹스트를
  고른 것」을 가름 — 값을 미리 넣으면 그 구별이 사라짐. 되돌리기도 됨.
- 물구멍 근거에 잠정값을 적음 — 「관 Ø 미정(법 3~6㎝ / 실무 Ø50) ·
  간격 2.0㎡당 1개소(법 2~3㎡당 1개소 이상)」. 「미확정」만으로는 무엇을
  정해야 하는지 모름.
- 준비공의 벌목 줄에 공종 미확정 사유와 후보를 함께 적음(수확베기·단목베기·
  위험목 베기 중 어느 것인지 원본이 말하지 않음).

검증 — 전체 580 passed, tsc 오류 0. 화면에서 셋 다 뜨는 것과 타설 방식
저장·되돌리기까지 확인 후 검증으로 바꾼 값은 원래대로 복원.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-08 01:14:34 +09:00
co-authored by Claude Opus 5
parent c12b1027f7
commit 24fc6ec090
8 changed files with 102 additions and 6 deletions
@@ -55,7 +55,11 @@ def preparation_rows(slope_totals: dict[str, float] | None = None) -> list[dict[
"status": STATUS_COUNTED_ELSEWHERE, "status": STATUS_COUNTED_ELSEWHERE,
# ⚠ 값을 여기서 또 내면 같은 나무를 두 번 벤다. 참고로 면적만 보인다. # ⚠ 값을 여기서 또 내면 같은 나무를 두 번 벤다. 참고로 면적만 보인다.
"reference_amount": tree_area, "reference_amount": tree_area,
"reason": "토공집계의 「지장목제거」로 이미 섬 — 여기서 또 세우면 이중계상", "reason": (
"토공집계의 「지장목제거」로 이미 섬 — 여기서 또 세우면 이중계상. "
"⚠ 다만 **공종 미확정** — 품셈 4장이 벌목을 목적별로 갈라(수확베기·단목베기·"
"위험목 베기) 임도 지장목이 어디에 붙는지 원본이 말하지 않음. 지금은 공종코드 없이 감."
),
"work_item_code": None, "work_item_code": None,
}, },
{ {
@@ -238,7 +238,10 @@ def stone_masonry(
"m", "m",
masonry_area / constants["weep_hole_area_m2"] * constants["weep_hole_length_m"], masonry_area / constants["weep_hole_area_m2"] * constants["weep_hole_length_m"],
DESTINATION["물구멍"], DESTINATION["물구멍"],
"돌쌓기 ÷ 2㎡/개소 × 0.5 m/개소 (관 규격 미확정)", # ⚠ 「미확정」만 적으면 사용자가 무엇을 정해야 하는지 모른다 —
# **지금 무슨 값으로 돌고 있는지**를 함께 적는다(원단위 미확보와 같은 방식).
"돌쌓기 ÷ 2㎡/개소 × 0.5 m/개소 · ⚠ 잠정: 관 Ø 미정(법 3~6㎝ / 실무 Ø50) ·"
" 간격 2.0㎡당 1개소(법 2~3㎡당 1개소 이상)",
) )
) )
+15 -1
View File
@@ -36,8 +36,10 @@ from B08_Quantity.B08_Quantity_Engine_HaulSummary import summary_input_rows
from B08_Quantity.B08_Quantity_Engine_SlopeArea import build_table as build_slope_table from B08_Quantity.B08_Quantity_Engine_SlopeArea import build_table as build_slope_table
from B08_Quantity.B08_Quantity_Engine_SlopeLength import station_slopes from B08_Quantity.B08_Quantity_Engine_SlopeLength import station_slopes
from common_util.common_util_project_settings import ( from common_util.common_util_project_settings import (
CONCRETE_PLACING_METHODS,
ROCK_METHODS, ROCK_METHODS,
application_ratio, application_ratio,
concrete_placing_method,
quantity_settings, quantity_settings,
rock_classes, rock_classes,
save_section, save_section,
@@ -99,6 +101,9 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
table["preparation"] = build_preparation_table( table["preparation"] = build_preparation_table(
slope.get("totals") or {}, await _route_structures(project_id) slope.get("totals") or {}, await _route_structures(project_id)
) )
method, method_is_default = concrete_placing_method(settings)
# ⚠ 금액에 바로 걸리는 값이라 「기본값으로 돌고 있음」을 응답에 실어 화면이 띄우게 한다.
table["concrete_placing"] = {"method": method, "is_default": method_is_default}
table["settings"] = settings table["settings"] = settings
table["project_root_known"] = project_root is not None table["project_root_known"] = project_root is not None
table["route_id"] = route_id table["route_id"] = route_id
@@ -164,6 +169,8 @@ class QuantitySettingsBody(BaseModel):
# 자재별 관급/사급 — `{자재명: {"supply": …, "install_by": …}}`. # 자재별 관급/사급 — `{자재명: {"supply": …, "install_by": …}}`.
# 표 안에서 줄마다 고른 값이 여기로 온다(2026-09-07 확정). # 표 안에서 줄마다 고른 값이 여기로 온다(2026-09-07 확정).
material_supply: dict[str, Any] | None = None material_supply: dict[str, Any] | None = None
# 콘크리트 타설 방식. `""` 는 「안 정함」으로 되돌리는 뜻이라 서버가 None 으로 만든다.
concrete_placing_method: str | None = None
@router.put("/{project_id}/quantity/settings") @router.put("/{project_id}/quantity/settings")
@@ -183,6 +190,10 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."}, content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."},
) )
values = {key: value for key, value in body.model_dump().items() if value is not None} values = {key: value for key, value in body.model_dump().items() if value is not None}
if "concrete_placing_method" in values:
method = values["concrete_placing_method"]
# 「안 정함」으로 되돌릴 수 있어야 한다 — 빈 값이면 지운다(8-22 ② 와 같은 자리).
values["concrete_placing_method"] = method if method in CONCRETE_PLACING_METHODS else None
if "rock_methods" in values: if "rock_methods" in values:
# 「안 정함」(빈 값)은 저장하지 않는다 — 정한 것과 구별이 안 된다. 통째로 갈아 끼우므로 # 「안 정함」(빈 값)은 저장하지 않는다 — 정한 것과 구별이 안 된다. 통째로 갈아 끼우므로
# 여기서 버리면 그 갈래는 미지정으로 돌아간다. # 여기서 버리면 그 갈래는 미지정으로 돌아간다.
@@ -195,7 +206,10 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
# ⚠ 고른 값을 **되돌릴 수 있어야** 하는 칸은 통째로 갈아 끼운다 — 병합이면 # ⚠ 고른 값을 **되돌릴 수 있어야** 하는 칸은 통째로 갈아 끼운다 — 병합이면
# 「안 정함」으로 되돌아가지 않는다(2026-09-07 화면에서 걸린 자리). # 「안 정함」으로 되돌아가지 않는다(2026-09-07 화면에서 걸린 자리).
saved = await asyncio.to_thread( saved = await asyncio.to_thread(
_save_quantity, root, values, ("rock_methods", "material_supply") _save_quantity,
root,
values,
("rock_methods", "material_supply", "concrete_placing_method"),
) )
except Exception: except Exception:
logger.exception("B08 설정 저장 실패(쓰기): project_id=%s", project_id) logger.exception("B08 설정 저장 실패(쓰기): project_id=%s", project_id)
@@ -63,6 +63,8 @@ export interface QuantitySettings {
rock_methods?: Record<string, string>; rock_methods?: Record<string, string>;
/** 자재별 관급/사급 — 표 안에서 줄마다 고른 값. */ /** 자재별 관급/사급 — 표 안에서 줄마다 고른 값. */
material_supply?: Record<string, { supply: string; install_by: string | null }>; material_supply?: Record<string, { supply: string; install_by: string | null }>;
/** 콘크리트 타설 방식. `null`·없음이면 **아직 안 정한 것**이고 화면이 기본값 안내를 띄운다. */
concrete_placing_method?: string | null;
} }
export interface EarthworkTable { export interface EarthworkTable {
@@ -32,6 +32,18 @@ const CSS = `
box-shadow: inset 2px 0 0 var(--color-accent, #6c8ebf); box-shadow: inset 2px 0 0 var(--color-accent, #6c8ebf);
} }
/* 잠정값 안내 — 금액에 걸리는 값이 조용히 기본으로 돌지 않게. */
.b08-quantity__notice {
margin: 0.35rem 0 0;
padding: 0.4rem 0.5rem;
font-size: 0.85em;
line-height: 1.4;
color: var(--color-text);
background: var(--color-surface-raised);
border-left: 3px solid var(--color-warning, #c08a3e);
border-radius: 3px;
}
.b08-grid__caption { .b08-grid__caption {
margin: 0; margin: 0;
font-size: 12px; font-size: 12px;
+33
View File
@@ -83,6 +83,7 @@ async function saveQuantitySettings(projectId: string, draft: DraftSettings): Pr
// 병합해 옛 값이 남아 되돌릴 길이 없다(화면에서 걸린 자리). 빈 값은 서버가 버린다. // 병합해 옛 값이 남아 되돌릴 길이 없다(화면에서 걸린 자리). 빈 값은 서버가 버린다.
rock_methods: draft.rock_methods, rock_methods: draft.rock_methods,
material_supply: draft.material_supply, material_supply: draft.material_supply,
concrete_placing_method: draft.concrete_placing_method,
}), }),
}, },
); );
@@ -172,6 +173,8 @@ interface DraftSettings {
application_ratios_pct: Record<string, number>; application_ratios_pct: Record<string, number>;
// 갈래별 시공법 — `""` 는 「안 정함」이고 저장에서 빠진다. // 갈래별 시공법 — `""` 는 「안 정함」이고 저장에서 빠진다.
rock_methods: Record<string, string>; rock_methods: Record<string, string>;
// 콘크리트 타설 방식 — `""` 는 「안 정함」이고 저장에서 지워진다.
concrete_placing_method: string;
// 자재별 관급/사급 — 표 안에서 줄마다 고른 값. // 자재별 관급/사급 — 표 안에서 줄마다 고른 값.
material_supply: Record<string, SupplyChoice>; material_supply: Record<string, SupplyChoice>;
dirty: boolean; dirty: boolean;
@@ -252,6 +255,35 @@ function buildQuantitySidePanel(
} }
} }
// ── 콘크리트 타설 방식 — ⚠ **금액에 바로 걸리는 값**이라 잠정임을 조용히 두지 않는다 ──
panel.append(field(L("B08_Quantity_Side_Placing"), ""));
panel.append(
selectField(
L("B08_Quantity_Side_Placing_Label"),
draft.concrete_placing_method,
[
{ value: "", label: L("B08_Quantity_Placing_Unset") },
{ value: "ready_mixed", label: L("B08_Quantity_Placing_Ready") },
{ value: "machine_mixed", label: L("B08_Quantity_Placing_Machine") },
{ value: "hand_mixed", label: L("B08_Quantity_Placing_Hand") },
],
(value) => {
draft.concrete_placing_method = value;
draft.dirty = true;
},
),
);
const placing = (
table as unknown as { concrete_placing?: { method: string; is_default: boolean } }
).concrete_placing;
if (placing?.is_default) {
// 「확인 필요」만 있으면 무엇을 정해야 하는지 모른다 — **지금 무엇으로 돌고 있는지**를 함께 적는다.
const notice = document.createElement("p");
notice.className = "b08-quantity__notice";
notice.textContent = L("B08_Quantity_Placing_Default_Notice");
panel.append(notice);
}
const saveButton = createButton({ const saveButton = createButton({
label: L("B08_Quantity_Btn_Save"), label: L("B08_Quantity_Btn_Save"),
variant: "ghost", variant: "ghost",
@@ -431,6 +463,7 @@ export async function renderB08Quantity(root: HTMLElement): Promise<void> {
rock_ratios_pct: { ...(stored.rock_ratios_pct ?? {}) }, rock_ratios_pct: { ...(stored.rock_ratios_pct ?? {}) },
application_ratios_pct: { ...(stored.application_ratios_pct ?? {}) }, application_ratios_pct: { ...(stored.application_ratios_pct ?? {}) },
rock_methods: { ...((stored.rock_methods ?? {}) as Record<string, string>) }, rock_methods: { ...((stored.rock_methods ?? {}) as Record<string, string>) },
concrete_placing_method: (stored.concrete_placing_method as string) ?? "",
material_supply: { ...((stored.material_supply ?? {}) as Record<string, SupplyChoice>) }, material_supply: { ...((stored.material_supply ?? {}) as Record<string, SupplyChoice>) },
dirty: false, dirty: false,
}; };
+21 -3
View File
@@ -94,9 +94,10 @@ def default_settings() -> dict[str, Any]:
# 화면에 드러난다 — 사급으로 조용히 넘기면 관급자재대가 새 나간다. # 화면에 드러난다 — 사급으로 조용히 넘기면 관급자재대가 새 나간다.
"material_supply": {}, "material_supply": {},
# 콘크리트 타설 방식 — `ready_mixed`(FP-12-01-01) / `machine_mixed`(-02) / # 콘크리트 타설 방식 — `ready_mixed`(FP-12-01-01) / `machine_mixed`(-02) /
# `hand_mixed`(-03). **설계 판단**이라 사용자가 고르는 값이고, 기본은 실무가 # `hand_mixed`(-03). **설계 판단**이라 사용자가 고른다.
# 쓰는 레디믹스트로 둔다. ⚠ 잠정이며 사용자 확정 대기 항목이다. # ⚠ 기본은 `None` — 「안 정함」과 「일부러 레디믹스트를 고른 것」을 갈라야
"concrete_placing_method": "ready_mixed", # 화면이 「기본값 적용 중」을 정직하게 띄운다. 값을 미리 넣으면 그 구별이 사라진다.
"concrete_placing_method": None,
"dataset_versions": {}, "dataset_versions": {},
}, },
"estimation": { "estimation": {
@@ -189,6 +190,23 @@ def rock_classes(settings: dict[str, Any]) -> list[str]:
return list(ROCK_CLASS_SETS.get(name, ROCK_CLASS_SETS[DEFAULT_ROCK_CLASS_SET])) return list(ROCK_CLASS_SETS.get(name, ROCK_CLASS_SETS[DEFAULT_ROCK_CLASS_SET]))
#: 타설 방식 — 안 정했을 때 쓰는 값. **금액에 바로 걸리는 자리**라 화면이 잠정임을 띄운다.
CONCRETE_PLACING_METHODS = ("ready_mixed", "machine_mixed", "hand_mixed")
DEFAULT_CONCRETE_PLACING_METHOD = "ready_mixed"
def concrete_placing_method(settings: dict[str, Any]) -> tuple[str, bool]:
"""(타설 방식, 기본값을 쓰고 있는가).
둘째 값이 참이면 **사용자가 아직 정한 **이다 화면이 기본값 적용 띄워야
한다. 조용히 기본으로 돌면 사용자는 그것이 잠정인 줄도 모른다.
"""
stored = settings.get("concrete_placing_method")
if stored in CONCRETE_PLACING_METHODS:
return str(stored), False
return DEFAULT_CONCRETE_PLACING_METHOD, True
def application_ratio(settings: dict[str, Any], key: str) -> float: def application_ratio(settings: dict[str, Any], key: str) -> float:
"""반영률을 0~1 로. 없으면 100 %(=1.0) — 실무 관측치를 기본값으로 쓰지 않는다.""" """반영률을 0~1 로. 없으면 100 %(=1.0) — 실무 관측치를 기본값으로 쓰지 않는다."""
raw = (settings.get("application_ratios_pct") or {}).get(key, 100) raw = (settings.get("application_ratios_pct") or {}).get(key, 100)
+10
View File
@@ -628,6 +628,16 @@ export const ui_locales_b2 = {
B08_Quantity_Method_Unset: ["안 정함", "Not set"], B08_Quantity_Method_Unset: ["안 정함", "Not set"],
B08_Quantity_Method_Ripping: ["긁어내기(암절취)", "Ripping"], B08_Quantity_Method_Ripping: ["긁어내기(암절취)", "Ripping"],
B08_Quantity_Method_Blasting: ["터뜨리기(발파암)", "Blasting"], B08_Quantity_Method_Blasting: ["터뜨리기(발파암)", "Blasting"],
B08_Quantity_Side_Placing: ["콘크리트 타설", "Concrete Placing"],
B08_Quantity_Side_Placing_Label: ["타설 방식", "Method"],
B08_Quantity_Placing_Unset: ["안 정함(기본값 사용)", "Not set (default)"],
B08_Quantity_Placing_Ready: ["레디믹스트", "Ready-mixed"],
B08_Quantity_Placing_Machine: ["기계비빔", "Machine-mixed"],
B08_Quantity_Placing_Hand: ["인력비빔", "Hand-mixed"],
B08_Quantity_Placing_Default_Notice: [
"⚠ 기본값 「레디믹스트」로 계산 중 — 아직 안 정한 값입니다. 타설 방식에 따라 단가가 갈립니다.",
"⚠ Using the default (ready-mixed) — not yet decided. Unit prices differ by method.",
],
B08_Quantity_Material_Failed: [ B08_Quantity_Material_Failed: [
"자재총괄을 불러오지 못했습니다.", "자재총괄을 불러오지 못했습니다.",
"Failed to load the material summary.", "Failed to load the material summary.",