feat(B08): 준비공·사방공 탭 + 페이지 최종 점검

⑪ 준비공·사방공 — 자리를 만들되 없는 값을 지어내지 않음.
- ⚠ 벌목은 값을 안 냄. 토공집계의 「지장목제거」로 이미 서 있어 또 세우면 같은
  나무를 두 번 벰. 참고 면적만 보이고 「다른 표에서 이미 섬」으로 가리킴.
- 못 서는 줄에 사유를 적음 — 표토제거(두께·구간 미정) · 제근(입목 본수 없음) ·
  규준틀(개소 기준 미정). 공종코드는 미리 적어 둠.
- 사방공은 레지스트리의 실제 type_id 로 봄. 없으면 「해당 없음」 — 0 을 적지 않음.
  이름을 지어내면 영영 안 걸리므로 레지스트리와 대조하는 시험을 둠.

⑫ 최종 점검 — 탭 6장 전수를 실화면에서 돌려 값으로 서는 것 확인
(토적표 65측점 · 토공집계 10줄 · 운반거리 안내 · 준비공 5줄 · 구조물 원단위 10줄 ·
자재총괄 4줄). 전체 566 passed, tsc 오류 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-08 00:50:35 +09:00
co-authored by Claude Opus 5
parent 8e78940b7a
commit e90a5703ee
5 changed files with 244 additions and 1 deletions
@@ -0,0 +1,138 @@
"""준비공·사방공 — **자리를 만들되 없는 값을 지어내지 않는다** (B08 일감 ⑪ · PLAN 8-3).
8-3 대응표에서 ❌ 로 남아 있던 둘이다. 여기서 하는 일은 **줄을 세우고, 설 수 있는 줄은
값을 채우고, 못 서는 줄은 왜 못 서는지 적는 것**이다. 빈 표를 내면 「빠뜨린 것」과
「원래 없는 것」이 구별되지 않는다.
⚠⚠ 지장목제거와 겹치지 않는다 (이중계상)
벌목·지장목제거는 **이미 토공집계의 사면 계열로 서 있다**(`tree_removal_*` × 반영률).
여기서 또 세우면 같은 나무를 두 번 벤다. 그래서 준비공의 벌목 줄은 **값을 내지 않고
「토공집계 지장목제거로 이미 섬」이라고 가리키기만** 한다.
⚠ 값이 없는 줄의 사유를 적는다
· 표토제거(9-15) — 면적은 사면적에서 나오나 **두께·대상 구간이 설계로 안 정해져 있다**.
· 제근·뿌리다듬기(9-20~21) — 단위가 **「개」(그루 수)**인데 입목 본수를 우리가 안 든다.
· 규준틀(11-2) — **개소** 산정 기준(구조물·절성토 구간별 몇 개소)이 안 정해져 있다.
⚠ 사방공은 이 노선에 실물이 없으면 「해당 없음」이다
있는 것처럼 0 을 적지 않는다. 구조물 목록에 사방 시설이 서면 그때 값이 선다.
"""
from __future__ import annotations
from typing import Any, Iterable
#: 사방 시설로 보는 구조물 종류 — **레지스트리의 실제 `type_id` 를 쓴다**(D 그룹 + 흙막이).
#: 목록에 없으면 그 노선에 사방공이 **없는** 것이다. 이름을 지어내면 영영 안 걸린다.
EROSION_CONTROL_TYPES = frozenset(
{
"erosion_check", # 골막이
"bed_sill", # 바닥막이
"check_dam_small", # 소형사방댐(복합형)
"revetment", # 기슭막이
"soil_guard", # 흙막이
}
)
STATUS_COUNTED_ELSEWHERE = "다른 표에서 이미 섬"
STATUS_PENDING = "값을 낼 근거가 없음"
STATUS_NOT_APPLICABLE = "해당 없음"
STATUS_READY = "값 있음"
def preparation_rows(slope_totals: dict[str, float] | 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)
)
return [
{
"group": "준비공",
"item": "벌목·지장목제거",
"unit": "",
"amount": None,
"status": STATUS_COUNTED_ELSEWHERE,
# ⚠ 값을 여기서 또 내면 같은 나무를 두 번 벤다. 참고로 면적만 보인다.
"reference_amount": tree_area,
"reason": "토공집계의 「지장목제거」로 이미 섬 — 여기서 또 세우면 이중계상",
"work_item_code": None,
},
{
"group": "준비공",
"item": "표토제거",
"unit": "",
"amount": None,
"status": STATUS_PENDING,
"reason": "면적은 사면적에서 나오나 **두께·대상 구간**이 설계로 안 정해져 있음 (품셈 9-15)",
"work_item_code": "FP-09-15",
},
{
"group": "준비공",
"item": "제근·뿌리다듬기",
"unit": "",
"amount": None,
"status": STATUS_PENDING,
"reason": "단위가 「개」(그루 수)인데 입목 본수를 들고 있지 않음 (품셈 9-20~21)",
"work_item_code": "FP-09-21",
},
{
"group": "준비공",
"item": "규준틀",
"unit": "개소",
"amount": None,
"status": STATUS_PENDING,
"reason": "개소 산정 기준(구조물·절성토 구간별 몇 개소)이 안 정해져 있음 (품셈 11-2)",
"work_item_code": "FP-11-02",
},
]
def erosion_rows(structures: Iterable[dict[str, Any]] = ()) -> list[dict[str, Any]]:
"""사방공 줄 — 이 노선에 사방 시설이 **있을 때만** 값이 선다."""
found = sorted(
{
str(item.get("type_id"))
for item in structures
if str(item.get("type_id")) in EROSION_CONTROL_TYPES
}
)
if not found:
return [
{
"group": "사방공",
"item": "사방 시설",
"unit": "",
"amount": None,
"status": STATUS_NOT_APPLICABLE,
"reason": "이 노선에 사방 시설이 배치돼 있지 않음 — 있는 것처럼 0 을 적지 않음",
"work_item_code": None,
}
]
return [
{
"group": "사방공",
"item": type_id,
"unit": "개소",
"amount": None,
"status": STATUS_PENDING,
"reason": "구조물 원단위가 아직 없음 — 전개식·관측값 모두 미확보",
"work_item_code": None,
}
for type_id in found
]
def build_table(
slope_totals: dict[str, float] | None = None,
structures: Iterable[dict[str, Any]] = (),
) -> dict[str, Any]:
"""화면·인계가 그대로 쓰는 모양. **못 서는 줄도 목록에 남는다.**"""
rows = preparation_rows(slope_totals) + erosion_rows(structures)
return {
"columns": ["구분", "공종", "단위", "수량", "상태", "사유"],
"rows": rows,
"ready_count": sum(1 for row in rows if row["status"] == STATUS_READY),
"pending_count": sum(1 for row in rows if row["status"] == STATUS_PENDING),
"row_count": len(rows),
}
@@ -31,6 +31,7 @@ from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SummaryInput
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table as build_summary_table
from B08_Quantity.B08_Quantity_Engine_EarthworkTable import StationArea, build_table
from B08_Quantity.B08_Quantity_Engine_HaulSummary import build_table as build_haul_table
from B08_Quantity.B08_Quantity_Engine_Preparation import build_table as build_preparation_table
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
@@ -94,12 +95,30 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
},
)
)
# 준비공·사방공 — 못 서는 줄도 사유와 함께 남긴다(빈 표는 「빠뜨림」과 구별이 안 됨).
table["preparation"] = build_preparation_table(
slope.get("totals") or {}, await _route_structures(project_id)
)
table["settings"] = settings
table["project_root_known"] = project_root is not None
table["route_id"] = route_id
return JSONResponse(content=table)
async def _route_structures(project_id: UUID) -> list[dict[str, Any]]:
"""배치된 구조물 목록 — 사방 시설이 있는지 보려는 것뿐이다. 없으면 빈 목록."""
try:
stored_path = await run_with_connection(get_project_storage_relative_path, project_id)
root = resolve_stored_project_path(stored_path)
from B05_Profile.B05_Profile_Structures_Repository import load_structures
_revision, items = load_structures(root)
return [item.model_dump() for item in items]
except Exception:
logger.warning("B08 준비공 — 구조물 목록을 못 읽음: project_id=%s", project_id)
return []
async def _project_settings(project_id: UUID) -> tuple[dict[str, Any], str | None]:
"""프로젝트 설정을 읽는다. 경로를 못 찾아도 기본값으로 화면은 선다."""
try:
+15 -1
View File
@@ -19,7 +19,12 @@ import {
} from "../A00_Common/b_workflow_nav";
import { renderEarthworkGrid, type EarthworkTable } from "./B08_Quantity_UI_EarthworkGrid";
import { injectEarthworkGridStyles } from "./B08_Quantity_UI_EarthworkGrid_Style";
import { renderHaulGrid, renderSummaryGrid } from "./B08_Quantity_UI_SummaryGrid";
import {
renderHaulGrid,
renderPreparationGrid,
renderSummaryGrid,
type PreparationTable,
} from "./B08_Quantity_UI_SummaryGrid";
import {
renderMaterialGrid,
renderUnitQuantityGrid,
@@ -346,6 +351,15 @@ function buildQuantityBody(
? renderHaulGrid(table.haul, Boolean(table.haul_available))
: message(L("B08_Quantity_Haul_Missing")),
},
{
label: L("B08_Quantity_Tab_Preparation"),
build: () => {
const preparation = (table as unknown as { preparation?: PreparationTable }).preparation;
return preparation
? renderPreparationGrid(preparation)
: message(L("B08_Quantity_Grid_Empty"));
},
},
{
label: L("B08_Quantity_Tab_UnitQuantity"),
build: () =>
@@ -188,3 +188,74 @@ export function renderHaulGrid(table: HaulTable, available: boolean): HTMLElemen
wrap.append(scroller);
return wrap;
}
export interface PreparationRow {
group: string;
item: string;
unit: string;
amount: number | null;
status: string;
reason: string;
reference_amount?: number;
work_item_code: string | null;
}
export interface PreparationTable {
columns: string[];
rows: PreparationRow[];
pending_count: number;
row_count: number;
}
/** 준비공·사방공 — **못 서는 줄도 보인다.**
*
* 빈 표를 내면 「빠뜨린 것」과 「원래 없는 것」이 구별되지 않는다. 그래서 값이 없는 줄도
* 상태와 사유를 달아 그대로 세운다.
*/
export function renderPreparationGrid(table: PreparationTable): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b08-grid";
const caption = document.createElement("p");
caption.className = "b08-grid__caption";
caption.textContent = `${table.row_count}줄 · 값을 낼 근거가 아직 없는 줄 ${table.pending_count}`;
wrap.append(caption);
const scroller = document.createElement("div");
scroller.className = "b08-grid__scroll";
const element = document.createElement("table");
element.className = "b08-grid__table b08-grid__table--summary";
const head = document.createElement("thead");
const headRow = document.createElement("tr");
for (const label of table.columns) {
const th = document.createElement("th");
th.textContent = label;
headRow.append(th);
}
head.append(headRow);
const body = document.createElement("tbody");
let lastGroup = "";
for (const row of table.rows) {
const tr = document.createElement("tr");
tr.append(textCell(row.group === lastGroup ? "" : row.group, "b08-grid__station"));
lastGroup = row.group;
tr.append(textCell(row.item));
tr.append(textCell(row.unit, "b08-grid__unit"));
// 값이 없으면 빈칸이 아니라 「-」 — 빈칸이면 0 으로 오해된다.
tr.append(textCell(row.amount === null ? "" : num(row.amount, 2)));
tr.append(textCell(row.status));
const note = textCell(row.reason.replace(/\*\*/g, ""), "b08-grid__note");
if (row.reference_amount) {
note.append(document.createTextNode(` (참고 면적 ${num(row.reference_amount, 1)})`));
}
tr.append(note);
body.append(tr);
}
element.append(head, body);
scroller.append(element);
wrap.append(scroller);
return wrap;
}
+1
View File
@@ -623,6 +623,7 @@ 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_Tab_Preparation: ["준비공·사방공", "Preparation & Erosion Control"],
B08_Quantity_Side_Method_Label: ["시공법", "Method"],
B08_Quantity_Method_Unset: ["안 정함", "Not set"],
B08_Quantity_Method_Ripping: ["긁어내기(암절취)", "Ripping"],