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:
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user