feat(B08): 암 시공법 칸 · 관급구분 표 입력 · 산출 크기 요약

- 암 시공법(ripping/blasting)을 갈래 비율 칸 아래에 붙임. 갈래 이름만으로는
  품셈 공종(암절취 FP-09-04 / 발파암 FP-09-05)을 못 고르던 자리. 기본은
  「안 정함」이고, 안 정하면 인계에서 사유와 함께 드러남. 비율 미입력 상태의
  「암」 한 줄에도 칸을 냄.
- 관급/사급은 표 안에서 줄마다 고름. 관급 줄에만 설치주체가 열리고 사급으로
  되돌리면 잠기며 비워짐. 만진 줄은 표시가 남음.
- 산출 요약(최소·중앙·최대)을 원단위·자재총괄·인계에 붙임. 단위별로 갈라 냄 —
  값이 있기만 하면 시험이 못 잡는 자릿수 어긋남을 사람이 훑게 하는 장치.

화면에서 걸려 고친 것 둘
- 관급을 골라도 설치주체 칸이 잠긴 채 남던 것. 고른 즉시 열고 닫게 함.
- 한 번 고른 시공법을 되돌릴 길이 없던 것. 설정 저장이 병합이라 빈 값을 보내도
  옛 값이 남았음. `save_section(replace_keys=…)` 로 되돌릴 수 있어야 하는 칸만
  통째로 갈아 끼움. 나머지는 그대로 병합.

앞 커밋에서 온 타입 오류 2건도 고침 (`slopeColumnCount` 미사용,
`variant: "outlined"` 는 없는 값). 앞선 확인에서 npx 가 엉뚱한 패키지를 실행해
「오류 없음」으로 잘못 봤음 — tsc 는 config/node_modules 것으로 부를 것.

검증 — 새 시험 5건 + 인계 28건 통과, 전체 509 passed. tsc 오류 0.
화면 실조작으로 시공법 저장·되돌리기, 관급/설치주체 저장·되돌리기 확인 후
검증으로 바꾼 값은 원래대로 복원.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-08 00:18:00 +09:00
co-authored by Claude Opus 5
parent ab39c174aa
commit c7d917864c
12 changed files with 402 additions and 28 deletions
+52 -5
View File
@@ -34,6 +34,8 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterable
from common_util.common_util_quantity_spread import spread_by_unit
DATASET_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_work_item_mapping"
DATASET_PREFIX = "work_item_mapping_"
@@ -43,6 +45,11 @@ ORIGIN_STRUCTURE = "structure"
ORIGIN_SLOPE = "slope"
ORIGIN_HAUL = "haul"
#: 암 시공법 → 매핑표의 지반 이름. 품셈이 **긁어내기와 터뜨리기를 다른 공종**으로 두기 때문에
#: 갈래 이름(연암·보통암…)만으로는 공종을 못 고른다(2026-09-07 일감 9 실서버에서 드러남).
METHOD_TO_GROUND = {"ripping": "리핑암", "blasting": "발파암"}
NOTE_METHOD_MISSING = "시공법 미지정으로 공종을 못 고름"
#: 사면 계열 — 토공집계에 함께 실리지만 출처가 사면적이다.
SLOPE_GROUPS = frozenset({"성토면다짐", "초류종자살포", "지장목제거", "층따기", "면고르기"})
@@ -68,7 +75,7 @@ class WorkItemMapping:
structure: list[dict[str, Any]] = field(default_factory=list)
pending_user: dict[str, Any] = field(default_factory=dict)
def for_earthwork(self, group: str, ground: str | None) -> dict[str, Any] | None:
def for_earthwork(self, group: str, ground: str | None) -> dict[str, Any] | None: # noqa: D401
"""공종+지반유형으로 찾는다. 지반이 갈리지 않는 공종은 `ground` 없는 줄이 받는다."""
exact = [
row
@@ -121,8 +128,25 @@ def _spec_detail(structure: dict[str, Any]) -> str:
return "·".join(parts)
def _mapping_ground(ground: str | None, methods: dict[str, str | None]) -> tuple[str | None, str]:
"""갈래 이름을 **매핑표가 아는 이름**으로 바꾼다.
「토사」는 그대로 가고, 암 갈래는 **시공법이 정해져야** 리핑암·발파암으로 간다.
안 정했으면 `(None, 사유)` — 찍지 않는다. 잘못 찍으면 공종이 조용히 틀린다.
"""
if ground is None or ground == "토사":
return ground, ""
method = methods.get(ground)
mapped = METHOD_TO_GROUND.get(method or "")
if mapped:
return mapped, ""
return None, NOTE_METHOD_MISSING
def _earthwork_rows(
summary_table: dict[str, Any], mapping: WorkItemMapping
summary_table: dict[str, Any],
mapping: WorkItemMapping,
methods: dict[str, str | None],
) -> tuple[list[dict[str, Any]], list[str]]:
"""토공집계표 줄을 내역 줄로 옮긴다.
@@ -138,10 +162,12 @@ def _earthwork_rows(
ground = row.get("item") or None
origin = ORIGIN_SLOPE if group in SLOPE_GROUPS else ORIGIN_EARTHWORK
is_subtotal = group in SUBTOTAL_GROUPS
entry = mapping.for_earthwork(group, ground)
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")
if code is None and not is_subtotal:
unmatched.append(f"{group}({ground})" if ground else group)
label = f"{group}({ground})" if ground else group
unmatched.append(f"{label}{method_note}" if method_note else label)
rows.append(
{
"work_item_code": code,
@@ -157,6 +183,7 @@ def _earthwork_rows(
"spec_detail": "",
# 합계 줄과 무대 줄은 값은 내되 내역에 안 선다.
"in_bill": bool(row.get("in_bill", True)) and not is_subtotal,
"excavation_method": methods.get(ground) if ground else None,
"in_bill_reason": "집계 합계 줄 — 검산용"
if is_subtotal
else str(row.get("note") or ""),
@@ -268,14 +295,16 @@ def build_handoff(
mapping: WorkItemMapping | None = None,
ground_class_set: str | None = None,
ground_classes: list[str] | None = None,
ground_methods: dict[str, str | None] | None = None,
) -> dict[str, Any]:
"""B09 가 그대로 받는 모양. 없는 표는 건너뛰되 **빈 표와 구별해 적는다**."""
table = mapping or load_mapping()
work_items: list[dict[str, Any]] = []
unmatched: list[str] = []
methods = {key: value for key, value in (ground_methods or {}).items() if value}
if summary_table:
rows, misses = _earthwork_rows(summary_table, table)
rows, misses = _earthwork_rows(summary_table, table, methods)
work_items.extend(rows)
unmatched.extend(misses)
if haul_table:
@@ -294,11 +323,29 @@ def build_handoff(
# 갈래 세트 — 「연암」이 몇 갈래 중 하나인지 알아야 ④가 선다.
"ground_class_set": ground_class_set,
"ground_classes": list(ground_classes or []),
"ground_methods": dict(methods),
# 시공법을 안 정해 공종을 못 고른 갈래 — 화면이 이 목록으로 안내를 띄운다.
"missing_method_classes": sorted(
{
str(row.get("ground_class"))
for row in work_items
if row.get("ground_class")
and row.get("ground_class") != "토사"
and row.get("work_item_code") is None
and row.get("origin") == ORIGIN_EARTHWORK
}
),
# 자재 쪽에만 할증이 있다 — 작업 공종에는 없다.
"surcharge_applied_to_materials": bool((material_table or {}).get("surcharge_applied")),
"unmatched_work_items": sorted(set(unmatched)),
"mapping_pending_user": table.pending_user,
"mapping_edition": table.effective_date,
"quantity_spread": spread_by_unit(
[row for row in work_items if row["in_bill"]], value_key="quantity"
),
"material_spread": spread_by_unit(
[{"unit": row["unit"], "q": row["total_amount"]} for row in materials], value_key="q"
),
"bill_row_count": sum(1 for row in work_items if row["in_bill"]),
"excluded_row_count": sum(1 for row in work_items if not row["in_bill"]),
}
@@ -46,6 +46,8 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterable
from common_util.common_util_quantity_spread import spread_by_unit
# ── 데이터 자리 ──────────────────────────────────────────────────────
DATASET_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_material_surcharge"
DATASET_PREFIX = "material_surcharge_"
@@ -303,4 +305,9 @@ def build_table(
"double_count_warnings": verify_single_surcharge(unit_quantity_table),
"skipped_by_destination": skipped,
"row_count": len(ordered),
# 값의 크기가 말이 되나 — 자릿수 어긋남은 사람이 훑어야 보인다(단위별로 가른다).
"amount_spread": spread_by_unit(
[{"unit": row.unit, "amount": row.total_amount} for row in ordered],
value_key="amount",
),
}
@@ -34,6 +34,8 @@ import math
from dataclasses import dataclass, field
from typing import Any, Iterable
from common_util.common_util_quantity_spread import spread_by_unit
# ── 계수표 — 식에 박지 않고 여기서 고른다 ─────────────────────────────
# 돌 뒷길이(㎝)별 원단위. 출처: `original/실무문서/_원단위라이브러리_울진소광.md` 「돌뒷길이별 원단위표」.
# ⚠ 60㎝ 돌중량은 원본이 비어 있다 — 지어내지 않고 None 으로 둔다(PLAN 8-8 ㉮).
@@ -347,4 +349,5 @@ def build_table(
"surcharge_applied": False,
"mix_components_found": violations,
"structure_count": len(quantities),
"amount_spread": spread_by_unit(totals.values(), value_key="amount"),
}
+25 -1
View File
@@ -35,6 +35,7 @@ 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_SlopeLength import station_slopes
from common_util.common_util_project_settings import (
ROCK_METHODS,
application_ratio,
quantity_settings,
rock_classes,
@@ -130,7 +131,12 @@ class QuantitySettingsBody(BaseModel):
rock_class_set: str | None = None
rock_classes: list[str] | None = None
rock_ratios_pct: dict[str, float] | None = None
# 갈래별 시공법 — 값은 "ripping"·"blasting". 안 정한 갈래는 보내지 않는다.
rock_methods: dict[str, str] | None = None
application_ratios_pct: dict[str, float] | None = None
# 자재별 관급/사급 — `{자재명: {"supply": …, "install_by": …}}`.
# 표 안에서 줄마다 고른 값이 여기로 온다(2026-09-07 확정).
material_supply: dict[str, Any] | None = None
@router.put("/{project_id}/quantity/settings")
@@ -150,8 +156,20 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."},
)
values = {key: value for key, value in body.model_dump().items() if value is not None}
if "rock_methods" in values:
# 「안 정함」(빈 값)은 저장하지 않는다 — 정한 것과 구별이 안 된다. 통째로 갈아 끼우므로
# 여기서 버리면 그 갈래는 미지정으로 돌아간다.
values["rock_methods"] = {
name: method
for name, method in values["rock_methods"].items()
if method in ROCK_METHODS
}
try:
saved = await asyncio.to_thread(save_section, root, "quantity", values)
# ⚠ 고른 값을 **되돌릴 수 있어야** 하는 칸은 통째로 갈아 끼운다 — 병합이면
# 「안 정함」으로 되돌아가지 않는다(2026-09-07 화면에서 걸린 자리).
saved = await asyncio.to_thread(
_save_quantity, root, values, ("rock_methods", "material_supply")
)
except Exception:
logger.exception("B08 설정 저장 실패(쓰기): project_id=%s", project_id)
return JSONResponse(
@@ -161,6 +179,12 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
return JSONResponse(content={"status": "success", "quantity": saved.get("quantity") or {}})
def _save_quantity(
root: str, values: dict[str, Any], replace_keys: tuple[str, ...]
) -> dict[str, Any]:
return save_section(root, "quantity", values, replace_keys=replace_keys)
@router.get("/{project_id}/quantity/earthwork-table")
async def get_earthwork_table_for_current_route(project_id: UUID) -> JSONResponse:
"""경로를 안 주면 워크플로가 보고 있는 경로로 낸다 — 화면이 route_id 를 모를 때 쓴다."""
+6 -1
View File
@@ -28,7 +28,11 @@ from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff, 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 common_util.common_util_project_settings import quantity_settings, rock_classes
from common_util.common_util_project_settings import (
quantity_settings,
rock_classes,
rock_method,
)
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import run_with_connection
@@ -143,6 +147,7 @@ async def get_handoff(project_id: UUID) -> JSONResponse:
material_table=material_table,
ground_class_set=settings.get("rock_class_set"),
ground_classes=rock_classes(settings),
ground_methods={name: rock_method(settings, name) for name in rock_classes(settings)},
)
handoff["summary"] = summarize(handoff)
handoff["skipped_structures"] = skipped
@@ -59,6 +59,10 @@ export interface QuantitySettings {
rock_classes?: string[];
rock_ratios_pct?: Record<string, number>;
application_ratios_pct?: Record<string, number>;
/** 갈래별 시공법 — `"ripping"`·`"blasting"`. 안 정한 갈래는 아예 없다. */
rock_methods?: Record<string, string>;
/** 자재별 관급/사급 — 표 안에서 줄마다 고른 값. */
material_supply?: Record<string, { supply: string; install_by: string | null }>;
}
export interface EarthworkTable {
@@ -188,10 +192,6 @@ const PAIR_LABELS = ["단면적", "입 적"];
const flatColumns = (): Column[] => GROUPS.flatMap((g) => g.sub.flatMap((s) => s.cols));
/** 사면 열 개수 — 계열마다 (거리, 면적) 두 칸. */
const slopeColumnCount = (): number =>
SLOPE_GROUPS.reduce((n, group) => n + group.faces.length * 2, 0);
/** 측점 표기 — `20` → `NO.1`, `25` → `NO.1+5`. 실무 토적표가 이 모양이다. */
function stationLabel(chainage: number, interval = 20): string {
const no = Math.floor(chainage / interval);
@@ -13,6 +13,25 @@ const STYLE_ID = "b08-earthwork-grid-style";
const CSS = `
.b08-grid { display: flex; flex-direction: column; gap: 8px; min-width: 0; height: 100%; }
/* 표 안에서 고르는 칸 — 관급/사급처럼 **줄마다 갈리는 값**을 여기서 정한다. */
.b08-grid__select {
width: 100%;
min-width: 5.5rem;
padding: 0.15rem 0.25rem;
font: inherit;
color: var(--color-text);
background: var(--color-surface-raised);
border: 1px solid var(--color-border, rgba(128, 128, 128, 0.4));
border-radius: 3px;
}
.b08-grid__select:disabled {
opacity: 0.45; /* 사급 줄의 설치 주체 — 뜻이 없으므로 흐리게 둔다 */
}
/* 만진 줄은 표시가 남는다 — 무엇을 바꿨는지 보여야 한다. */
.b08-grid__table td.is-changed {
box-shadow: inset 2px 0 0 var(--color-accent, #6c8ebf);
}
.b08-grid__caption {
margin: 0;
font-size: 12px;
+115 -3
View File
@@ -33,6 +33,7 @@ export interface MaterialTable {
missing_rate_materials: string[];
missing_supply_materials: string[];
missing_install_by_materials: string[];
amount_spread: Record<string, { min: number; median: number; max: number; count: number }>;
double_count_warnings: string[];
skipped_by_destination: Record<string, number>;
row_count: number;
@@ -110,8 +111,78 @@ function warning(title: string, items: string[]): HTMLElement | null {
return element;
}
/** 관급/사급 고르는 칸의 보기. **값은 영문 키, 표기는 한글**(B09 와 같은 낱말). */
const SUPPLY_OPTIONS = [
{ value: "unknown", label: "미분류" },
{ value: "contractor_supplied", label: "사급" },
{ value: "owner_supplied", label: "관급" },
];
const INSTALL_BY_OPTIONS = [
{ value: "", label: "미지정" },
{ value: "contractor", label: "도급자설치" },
{ value: "owner", label: "관 직접설치" },
];
export interface SupplyChoice {
supply: string;
install_by: string | null;
}
export interface MaterialGridOptions {
/** 저장 전 변경분 — 고른 값은 여기 쌓이고 [저장]에서만 정본으로 간다. */
choices: Record<string, SupplyChoice>;
onChange: () => void;
}
/** 표 안의 고르는 칸. 바꾼 줄은 **표시가 남는다** — 무엇을 만졌는지 보여야 한다. */
function choiceCell(
value: string,
options: { value: string; label: string }[],
disabled: boolean,
onChange: (value: string) => void,
): HTMLTableCellElement {
const td = document.createElement("td");
const select = document.createElement("select");
select.className = "b08-grid__select";
for (const option of options) {
const element = document.createElement("option");
element.value = option.value;
element.textContent = option.label;
select.append(element);
}
select.value = value;
select.disabled = disabled;
select.addEventListener("change", () => {
onChange(select.value);
td.classList.add("is-changed");
});
td.append(select);
return td;
}
/** 값의 크기 요약 — 자릿수가 어긋난 것은 사람이 훑어야 보인다. */
function spreadLine(spread: MaterialTable["amount_spread"], title: string): HTMLElement | null {
const units = Object.keys(spread || {});
if (!units.length) return null;
const element = document.createElement("p");
element.className = "b08-grid__caption";
element.textContent =
title +
" " +
units
.map((unit) => {
const s = spread[unit];
return `${unit} 최소 ${num(s.min, 2)} · 중앙 ${num(s.median, 2)} · 최대 ${num(s.max, 2)}`;
})
.join(" / ");
return element;
}
/** 자재총괄표 — 할증이 붙는 유일한 자리. */
export function renderMaterialGrid(table: MaterialTable): HTMLElement {
export function renderMaterialGrid(
table: MaterialTable,
options?: MaterialGridOptions,
): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b08-grid";
@@ -121,6 +192,9 @@ export function renderMaterialGrid(table: MaterialTable): HTMLElement {
caption.textContent = `자재 ${table.row_count}종 · 할증률 ${edition} 판 적용 · 금액은 원가계산(B09)에서`;
wrap.append(caption);
const spread = spreadLine(table.amount_spread, "물량 크기:");
if (spread) wrap.append(spread);
for (const notice of [
warning("⚠ 중복 할증 위험", table.double_count_warnings),
warning("할증률 미확보", table.missing_rate_materials),
@@ -153,8 +227,46 @@ export function renderMaterialGrid(table: MaterialTable): HTMLElement {
// 미확보는 빈칸이 아니라 「-」 — 빈칸이면 0 % 로 오해된다.
tr.append(textCell(row.surcharge_pct === null ? "" : num(row.surcharge_pct, 0)));
tr.append(textCell(num(row.total_amount, 2)));
tr.append(textCell(row.supply_label));
tr.append(textCell(row.install_by_label));
if (options) {
// 관급/사급은 **자재마다 갈리는 발주 결정**이라 줄에서 고른다(2026-09-07 확정).
const chosen = options.choices[row.name] ?? {
supply: row.supply,
install_by: row.install_by,
};
const installCell = choiceCell(
chosen.install_by ?? "",
INSTALL_BY_OPTIONS,
chosen.supply !== "owner_supplied", // 관급 줄에만 고를 수 있다
(value) => {
const current = options.choices[row.name] ?? chosen;
options.choices[row.name] = { supply: current.supply, install_by: value || null };
options.onChange();
},
);
tr.append(
choiceCell(chosen.supply, SUPPLY_OPTIONS, false, (value) => {
const current = options.choices[row.name] ?? chosen;
const next = {
// 사급으로 되돌리면 설치 주체는 뜻을 잃으므로 비운다.
supply: value,
install_by: value === "owner_supplied" ? (current.install_by ?? null) : null,
};
options.choices[row.name] = next;
// ⚠ 표를 다시 그리지 않으므로 **여기서 바로 열고 닫는다** — 안 그러면 관급을 골라도
// 설치 주체 칸이 잠긴 채 남아 사용자가 못 정한다(만들고 화면에서 걸린 자리).
const select = installCell.querySelector("select") as HTMLSelectElement | null;
if (select) {
select.disabled = value !== "owner_supplied";
select.value = next.install_by ?? "";
}
options.onChange();
}),
);
tr.append(installCell);
} else {
tr.append(textCell(row.supply_label));
tr.append(textCell(row.install_by_label));
}
tr.append(textCell(row.note, "b08-grid__note"));
body.append(tr);
}
+84 -10
View File
@@ -74,6 +74,10 @@ async function saveQuantitySettings(projectId: string, draft: DraftSettings): Pr
rock_class_set: draft.rock_class_set ?? null,
rock_ratios_pct: draft.rock_ratios_pct,
application_ratios_pct: draft.application_ratios_pct,
// ⚠ 「안 정함」으로 되돌린 갈래까지 **통째로** 보낸다. 정한 것만 보내면 서버가
// 병합해 옛 값이 남아 되돌릴 길이 없다(화면에서 걸린 자리). 빈 값은 서버가 버린다.
rock_methods: draft.rock_methods,
material_supply: draft.material_supply,
}),
},
);
@@ -124,11 +128,47 @@ function numberField(label: string, value: number, onInput: (value: number) => v
return row;
}
/** 고르는 칸. 첫 보기는 **「안 정함」**이고 그것이 기본이다 — 찍으면 값이 조용히 틀린다. */
function selectField(
label: string,
value: string,
options: { value: string; label: string }[],
onChange: (value: string) => void,
): HTMLElement {
const row = document.createElement("label");
row.className = "b08-quantity__field";
const name = document.createElement("span");
name.textContent = label;
const select = document.createElement("select");
select.className = "b08-quantity__input";
for (const option of options) {
const element = document.createElement("option");
element.value = option.value;
element.textContent = option.label;
select.append(element);
}
select.value = value;
// 자동저장은 만들지 않는다 — 고른 값은 캐시에만 남는다(CLAUDE.md 5장).
select.addEventListener("change", () => onChange(select.value));
row.append(name, select);
return row;
}
/** 자재 한 줄의 관급/사급. `install_by` 는 **관급 줄에만** 뜻이 있다. */
export interface SupplyChoice {
supply: string;
install_by: string | null;
}
/** 저장 전 변경분 — 조작은 여기 쌓이고 [저장]에서만 정본으로 간다. */
interface DraftSettings {
rock_class_set?: string;
rock_ratios_pct: Record<string, number>;
application_ratios_pct: Record<string, number>;
// 갈래별 시공법 — `""` 는 「안 정함」이고 저장에서 빠진다.
rock_methods: Record<string, string>;
// 자재별 관급/사급 — 표 안에서 줄마다 고른 값.
material_supply: Record<string, SupplyChoice>;
dirty: boolean;
}
@@ -154,16 +194,42 @@ function buildQuantitySidePanel(
}
// ── 지반 구성비 — 갈래 수는 프로젝트 세트가 정한다(코드에 안 박음, PLAN 8-13) ──
const classes = table?.summary?.rock_classes ?? [];
const classes = [...(table?.summary?.rock_classes ?? [])];
// ⚠ 비율을 아직 안 넣었으면 집계가 **「암」 한 줄**로 나온다(갈래로 안 갈림). 그 줄에도
// 시공법을 정할 수 있어야 공종이 선다 — 그때만 칸을 하나 더 낸다.
const hasRockFallback = (table?.summary?.rows ?? []).some((row) => row.item === "암");
if (hasRockFallback && !classes.includes("암")) classes.push("암");
if (classes.length) {
panel.append(field(L("B08_Quantity_Side_RockRatios"), ""));
for (const name of classes) {
panel.append(
numberField(name, draft.rock_ratios_pct[name] ?? 0, (value) => {
draft.rock_ratios_pct[name] = value;
draft.dirty = true;
}),
);
// 「암」은 비율을 넣으면 사라지는 되메움 줄이라 비율 칸을 두지 않는다.
if (name !== "암") {
panel.append(
numberField(name, draft.rock_ratios_pct[name] ?? 0, (value) => {
draft.rock_ratios_pct[name] = value;
draft.dirty = true;
}),
);
}
// ⚠ 암 갈래는 **시공법까지 정해야** 공종이 갈린다 — 품셈이 긁어내기(암절취)와
// 터뜨리기(발파암)를 다른 공종으로 두기 때문이다. 「토사」에는 안 붙인다.
if (name !== "토사") {
panel.append(
selectField(
` ${name} ${L("B08_Quantity_Side_Method_Label")}`,
draft.rock_methods[name] ?? "",
[
{ value: "", label: L("B08_Quantity_Method_Unset") },
{ value: "ripping", label: L("B08_Quantity_Method_Ripping") },
{ value: "blasting", label: L("B08_Quantity_Method_Blasting") },
],
(value) => {
draft.rock_methods[name] = value;
draft.dirty = true;
},
),
);
}
}
}
@@ -183,7 +249,7 @@ function buildQuantitySidePanel(
const saveButton = createButton({
label: L("B08_Quantity_Btn_Save"),
variant: "outlined",
variant: "ghost",
onClick: () => {
if (!projectId) {
showToast(L("B08_Quantity_Save_Failed"), "error");
@@ -240,6 +306,7 @@ function buildQuantityBody(
table: EarthworkTable | null,
failed: boolean,
material: MaterialResponse | null,
draft: DraftSettings,
): HTMLElement {
const body = document.createElement("div");
body.className = "b08-quantity__body";
@@ -288,7 +355,12 @@ function buildQuantityBody(
label: L("B08_Quantity_Tab_Material"),
build: () =>
material
? renderMaterialGrid(material.material)
? renderMaterialGrid(material.material, {
choices: draft.material_supply,
onChange: () => {
draft.dirty = true;
},
})
: message(L("B08_Quantity_Material_Failed")),
},
];
@@ -344,6 +416,8 @@ export async function renderB08Quantity(root: HTMLElement): Promise<void> {
rock_class_set: stored.rock_class_set,
rock_ratios_pct: { ...(stored.rock_ratios_pct ?? {}) },
application_ratios_pct: { ...(stored.application_ratios_pct ?? {}) },
rock_methods: { ...((stored.rock_methods ?? {}) as Record<string, string>) },
material_supply: { ...((stored.material_supply ?? {}) as Record<string, SupplyChoice>) },
dirty: false,
};
const reload = (): void => {
@@ -372,7 +446,7 @@ export async function renderB08Quantity(root: HTMLElement): Promise<void> {
steps: workflowSteps(),
activeStep: 5,
leftPanel: buildQuantitySidePanel(projectId, table, draft, reload),
mainContent: buildQuantityBody(table, failed, material),
mainContent: buildQuantityBody(table, failed, material, draft),
stages: workflowState?.stages,
currentStage: workflowState?.current_stage,
routes: WORKFLOW_STEP_ROUTES,
+36 -4
View File
@@ -33,7 +33,7 @@ from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from typing import Any, Iterable
from common_util.common_util_json import atomic_write_json
@@ -57,6 +57,17 @@ ROCK_CLASS_SETS: dict[str, tuple[str, ...]] = {
}
DEFAULT_ROCK_CLASS_SET = "geochang5"
# 암 시공법 — 품셈이 공종을 가르는 기준. `None` 은 「아직 안 정함」이고 기본값이다.
ROCK_METHOD_RIPPING = "ripping" # 긁어내기 — 암절취
ROCK_METHOD_BLASTING = "blasting" # 터뜨리기 — 발파암
ROCK_METHODS = (ROCK_METHOD_RIPPING, ROCK_METHOD_BLASTING)
def rock_method(settings: dict[str, Any], rock_class: str) -> str | None:
"""갈래 하나의 시공법. 안 정했으면 `None` — **기본값으로 때우지 않는다.**"""
value = (settings.get("rock_methods") or {}).get(rock_class)
return value if value in ROCK_METHODS else None
def default_settings() -> dict[str, Any]:
"""빈 설정. `estimation` 은 **자리만** 만든다 — 채우는 것은 B09 몫이다."""
@@ -68,10 +79,17 @@ def default_settings() -> dict[str, Any]:
# 갈래별 비율(%). 설계자가 넣는 값이라 기본은 비워 둔다 —
# 측점별 암질 판정에 기대지 않는다는 것이 8-1 사용자 확정이다.
"rock_ratios_pct": {},
# 갈래별 **시공법** — `{갈래이름: "ripping"|"blasting"}`.
# ⚠ 갈래 이름(연암·보통암…)만으로는 **긁어내는 암인지 터뜨리는 암인지** 알 수 없고,
# 품셈은 그 둘을 다른 공종으로 둔다(암절취 FP-09-04 / 발파암 FP-09-05).
# 기본은 **비워 둔다** — 찍으면 공종이 조용히 틀린다. 안 정하면 인계에서
# 「시공법 미지정」으로 드러난다(2026-09-07 일감 9 에서 드러난 자리).
"rock_methods": {},
"conversion_factors_override": None,
"haul_limits_m_override": None,
"application_ratios_pct": {key: 100 for key in APPLICATION_RATIO_KEYS},
# 자재총괄의 관급/사급 구분 — `{자재명: "public"|"private"}`.
# 자재총괄의 관급/사급 구분 — `{자재명: "owner_supplied"|"contractor_supplied"}`
# 또는 `{자재명: {"supply": …, "install_by": "contractor"|"owner"}}`.
# ⚠ **법이 아니라 발주 결정**이라 기본은 비워 둔다. 안 정한 자재는 「미분류」로
# 화면에 드러난다 — 사급으로 조용히 넘기면 관급자재대가 새 나간다.
"material_supply": {},
@@ -124,16 +142,30 @@ def _merge(base: dict[str, Any], stored: dict[str, Any]) -> dict[str, Any]:
SECTIONS = ("quantity", "estimation")
def save_section(project_root: str | Path, section: str, values: dict[str, Any]) -> dict[str, Any]:
def save_section(
project_root: str | Path,
section: str,
values: dict[str, Any],
*,
replace_keys: Iterable[str] = (),
) -> dict[str, Any]:
"""한 구획만 갈아 끼운다 — 남의 구획은 **손대지 않는다**.
두 페이지가 같은 파일을 쓰므로 통째로 덮으면 상대 값이 사라진다. 그래서 **통째로 쓰는
함수를 두지 않는다** — 쓰려면 반드시 구획 이름을 대야 한다.
⚠ `replace_keys` — **지울 수 있어야 하는 칸**은 병합이 아니라 통째로 갈아 끼운다.
「고른 값을 안 정함으로 되돌리기」가 병합으로는 안 되기 때문이다(2026-09-07 화면에서
걸린 자리 — 시공법을 한 번 고르면 되돌릴 길이 없었다).
"""
if section not in SECTIONS:
raise ValueError(f"모르는 구획: {section} (쓸 수 있는 것: {', '.join(SECTIONS)})")
settings = load_settings(project_root)
settings[section] = _merge(settings.get(section) or {}, values)
merged = _merge(settings.get(section) or {}, values)
for key in replace_keys:
if key in values:
merged[key] = values[key]
settings[section] = merged
settings["schema_version"] = SCHEMA_VERSION
atomic_write_json(settings_path(project_root), settings)
return settings
@@ -0,0 +1,47 @@
"""산출 요약 — 값의 **크기가 말이 되나**를 한눈에 보이는 자리 (2026-09-07 조율 창 권고).
왜 있나
서브 창이 씨앗뿜어붙이기를 **합계 68.8원**으로 세워 두고도 몰랐던 일이 있었다.
값이 **있기는 하니** 어떤 시험도 안 잡는다. 자릿수가 어긋난 것은 사람이 훑어야 보이고,
훑으려면 **최솟값·중앙값·최댓값이 표 옆에 떠 있어야** 한다.
⚠ 이것은 검사가 아니라 **눈에 띄게 하는 장치**다
기준을 정해 놓고 걸러 내지 않는다 — 임도 물량은 ㎥·㎡·m·ton·개가 섞여 있어 「얼마 이하면
이상하다」를 한 벌로 못 정한다. **단위별로 나눠** 내고 판단은 사람에게 맡긴다.
"""
from __future__ import annotations
from statistics import median
from typing import Any, Iterable
def spread(values: Iterable[float]) -> dict[str, float] | None:
"""최솟값·중앙값·최댓값. 값이 없으면 `None` — 0 으로 만들지 않는다."""
numbers = [float(v) for v in values if isinstance(v, (int, float))]
if not numbers:
return None
return {
"min": min(numbers),
"median": float(median(numbers)),
"max": max(numbers),
"count": len(numbers),
}
def spread_by_unit(
rows: Iterable[dict[str, Any]], *, value_key: str
) -> dict[str, dict[str, float]]:
"""단위별로 갈라 낸다. ㎥ 와 ton 을 한 통에 넣으면 최솟값이 뜻을 잃는다."""
buckets: dict[str, list[float]] = {}
for row in rows:
value = row.get(value_key)
if not isinstance(value, (int, float)):
continue
buckets.setdefault(str(row.get("unit") or "?"), []).append(float(value))
result: dict[str, dict[str, float]] = {}
for unit, numbers in buckets.items():
found = spread(numbers)
if found:
result[unit] = found
return result
+4
View File
@@ -623,6 +623,10 @@ export const ui_locales_b2 = {
B08_Quantity_Tab_Haul: ["운반거리", "Haul Distance"],
B08_Quantity_Tab_UnitQuantity: ["구조물 원단위", "Structure Unit Quantity"],
B08_Quantity_Tab_Material: ["자재총괄", "Material Summary"],
B08_Quantity_Side_Method_Label: ["시공법", "Method"],
B08_Quantity_Method_Unset: ["안 정함", "Not set"],
B08_Quantity_Method_Ripping: ["긁어내기(암절취)", "Ripping"],
B08_Quantity_Method_Blasting: ["터뜨리기(발파암)", "Blasting"],
B08_Quantity_Material_Failed: [
"자재총괄을 불러오지 못했습니다.",
"Failed to load the material summary.",