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:
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -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 를 모를 때 쓴다."""
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user