feat(B08): 토적표에 사면 4계열 열·잘림 경고 붙임
일감 3 의 API·화면 덩어리. 토적표 오른쪽 절반(실무 V~AI)이 화면에 섬. 한 응답으로 냄 실무 토적표가 한 장이라 화면도 한 장임. 나눠 부르면 두 번 왕복하고 같은 측점 목록을 두 벌로 들게 됨. earthwork-table 응답에 slope 를 함께 실음. 열 구성 — 계열 4 x 성토면/절토면 = (거리, 면적) 7쌍 층따기[성토면] · 면고르기 · 법면보호공(종자파종) · 지장목제거. 거리 = 그 측점 사면길이, 면적은 토적표와 같은 평균단면적법. 합계행은 면적만 — 거리(사면길이)는 합이 뜻이 없음(실무 시트도 비움). ⚠ 잘린 측점을 감추지 않음 (PLAN 8-4b) 사면이 원지반을 못 만난 측점은 사면길이·면적이 그 지점에서 잘려 있음. 경고 문구에 **무엇이 잘렸는지**를 적고(「실제 값은 이보다 큼」), 측점 단추를 누르면 그 줄로 가서 잠깐 강조됨 — 17곳을 눈으로 찾게 하지 않음. 잘린 줄은 측점 칸 왼쪽에 표시가 남음. 검증 — 공용 브라우저 실화면. 타입검사 오류 0. 머리글 3단 13/12/28셀, 대분류에 층따기·면고르기·법면보호공·지장목제거, 3단에 거리/면적 쌍. 본문 65행 x 34열(토공 20 + 사면 14). NO.1 사면값 6.4/64.4 · 3.5/93.0 등이 엔진값과 일치. 잘린 줄 17개 표시 · 경고 문구 · 측점 단추 17개 확인. 합계 면고르기 성토 13,518.6㎡ · 절토 5,433.7㎡ 로 엔진 합계와 같음. 회귀 404 passed (실패 1건은 B05 코리도 기존 깨짐). 조작한 화면은 사용자가 보던 주소로 되돌려 놓음. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,8 @@ from B06_Section.B06_Section_Repository import (
|
|||||||
get_workflow_route_context,
|
get_workflow_route_context,
|
||||||
)
|
)
|
||||||
from B08_Quantity.B08_Quantity_Engine_EarthworkTable import StationArea, build_table
|
from B08_Quantity.B08_Quantity_Engine_EarthworkTable import StationArea, build_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 config.config_db import run_with_connection
|
from config.config_db import run_with_connection
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -38,7 +40,11 @@ def _stations(designs: list[dict[str, Any]]) -> list[StationArea]:
|
|||||||
|
|
||||||
@router.get("/{project_id}/quantity/{route_id}/earthwork-table")
|
@router.get("/{project_id}/quantity/{route_id}/earthwork-table")
|
||||||
async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
|
async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
|
||||||
"""토적표 — 측점별 단면적을 평균단면적법으로 체적화한 표."""
|
"""토적표 — 토공(체적)과 사면 4계열(면적)을 **한 응답**으로 낸다.
|
||||||
|
|
||||||
|
실무 토적표가 한 장이라 화면도 한 장이다. 나눠 부르면 두 번 왕복하고, 같은 측점 목록을
|
||||||
|
두 벌로 들게 된다.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
designs = await run_with_connection(get_cross_section_designs, route_id)
|
designs = await run_with_connection(get_cross_section_designs, route_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -48,6 +54,8 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
|
|||||||
content={"status": "error", "message": "토적표를 만들지 못했습니다."},
|
content={"status": "error", "message": "토적표를 만들지 못했습니다."},
|
||||||
)
|
)
|
||||||
table = build_table(_stations(designs))
|
table = build_table(_stations(designs))
|
||||||
|
# 사면 계열은 저장된 설계선에서 유도한다 — 반영률은 기본 100 %(설계자 입력은 후속).
|
||||||
|
table["slope"] = build_slope_table(station_slopes(designs))
|
||||||
table["route_id"] = route_id
|
table["route_id"] = route_id
|
||||||
return JSONResponse(content=table)
|
return JSONResponse(content=table)
|
||||||
|
|
||||||
|
|||||||
@@ -36,12 +36,31 @@ export interface EarthworkRow {
|
|||||||
cumulative_m3: number;
|
cumulative_m3: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 사면 4계열 — 계열별 (거리, 면적). 키는 `면고르기_성토면` 식으로 엔진과 같다. */
|
||||||
|
export interface SlopeRow {
|
||||||
|
chainage_m: number;
|
||||||
|
distance_m: number;
|
||||||
|
berm_width_m: number;
|
||||||
|
unclosed: boolean;
|
||||||
|
lengths: Record<string, number>;
|
||||||
|
areas: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SlopeTable {
|
||||||
|
rows: SlopeRow[];
|
||||||
|
totals: Record<string, number>;
|
||||||
|
ratios: Record<string, number>;
|
||||||
|
unclosed_stations: number[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface EarthworkTable {
|
export interface EarthworkTable {
|
||||||
method: string;
|
method: string;
|
||||||
station_count: number;
|
station_count: number;
|
||||||
route_id?: number;
|
route_id?: number;
|
||||||
rows: EarthworkRow[];
|
rows: EarthworkRow[];
|
||||||
totals: Record<string, number>;
|
totals: Record<string, number>;
|
||||||
|
conversion_factors?: Record<string, { compacted: number }>;
|
||||||
|
slope?: SlopeTable;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 열 하나. `digits` 는 **표기 자리**이며 값 자체는 자르지 않는다. */
|
/** 열 하나. `digits` 는 **표기 자리**이며 값 자체는 자르지 않는다. */
|
||||||
@@ -119,12 +138,46 @@ const GROUPS: { label: string; sub: { label: string; cols: Column[] }[] }[] = [
|
|||||||
{ label: "", sub: [{ label: "누가토량", cols: [{ key: "cumulative_m3", digits: 1 }] }] },
|
{ label: "", sub: [{ label: "누가토량", cols: [{ key: "cumulative_m3", digits: 1 }] }] },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** 사면 4계열 — 실무 토적표 오른쪽 절반(V~AI). 계열마다 (거리, 면적) 쌍이다.
|
||||||
|
* 키는 엔진과 같은 이름을 쓴다 — 이름이 어긋나면 값이 조용히 빈다. */
|
||||||
|
const SLOPE_GROUPS: { label: string; faces: { key: string; label: string }[] }[] = [
|
||||||
|
{ label: "층 따 기", faces: [{ key: "bench_cut_fill", label: "성 토 면" }] },
|
||||||
|
{
|
||||||
|
label: "면고르기",
|
||||||
|
faces: [
|
||||||
|
{ key: "face_dressing_fill", label: "성 토 면" },
|
||||||
|
{ key: "face_dressing_cut", label: "절 토 면" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "법 면 보 호 공",
|
||||||
|
faces: [
|
||||||
|
{ key: "slope_protection_fill", label: "종자파종(성토)" },
|
||||||
|
{ key: "slope_protection_cut", label: "종자파종(절토)" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "지 장 목 제 거",
|
||||||
|
faces: [
|
||||||
|
{ key: "tree_removal_fill", label: "성 토 면" },
|
||||||
|
{ key: "tree_removal_cut", label: "절 토 면" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 사면 계열의 소분류 머리글 — 거리(사면길이)와 면적 두 칸. */
|
||||||
|
const SLOPE_LABELS = ["거 리", "면 적"];
|
||||||
|
|
||||||
/** 소분류가 「단면적·입적·보정량」 삼중조일 때 붙는 3단 머리글 문구. */
|
/** 소분류가 「단면적·입적·보정량」 삼중조일 때 붙는 3단 머리글 문구. */
|
||||||
const TRIPLE_LABELS = ["단면적", "입 적", "보정량"];
|
const TRIPLE_LABELS = ["단면적", "입 적", "보정량"];
|
||||||
const PAIR_LABELS = ["단면적", "입 적"];
|
const PAIR_LABELS = ["단면적", "입 적"];
|
||||||
|
|
||||||
const flatColumns = (): Column[] => GROUPS.flatMap((g) => g.sub.flatMap((s) => s.cols));
|
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`. 실무 토적표가 이 모양이다. */
|
/** 측점 표기 — `20` → `NO.1`, `25` → `NO.1+5`. 실무 토적표가 이 모양이다. */
|
||||||
function stationLabel(chainage: number, interval = 20): string {
|
function stationLabel(chainage: number, interval = 20): string {
|
||||||
const no = Math.floor(chainage / interval);
|
const no = Math.floor(chainage / interval);
|
||||||
@@ -178,13 +231,35 @@ function buildHead(): HTMLTableSectionElement {
|
|||||||
r1.append(th);
|
r1.append(th);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 사면 4계열 — 대분류 / 면(성토·절토) / (거리·면적) 3단으로 같은 모양을 이어 붙인다.
|
||||||
|
for (const group of SLOPE_GROUPS) {
|
||||||
|
const th = document.createElement("th");
|
||||||
|
th.colSpan = group.faces.length * 2;
|
||||||
|
th.textContent = group.label;
|
||||||
|
r1.append(th);
|
||||||
|
for (const face of group.faces) {
|
||||||
|
const th2 = document.createElement("th");
|
||||||
|
th2.colSpan = 2;
|
||||||
|
th2.textContent = face.label;
|
||||||
|
r2.append(th2);
|
||||||
|
for (const label of SLOPE_LABELS) {
|
||||||
|
const th3 = document.createElement("th");
|
||||||
|
th3.textContent = label;
|
||||||
|
r3.append(th3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
head.append(r1, r2, r3);
|
head.append(r1, r2, r3);
|
||||||
return head;
|
return head;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildBody(rows: EarthworkRow[]): HTMLTableSectionElement {
|
function buildBody(rows: EarthworkRow[], slope?: SlopeTable): HTMLTableSectionElement {
|
||||||
const body = document.createElement("tbody");
|
const body = document.createElement("tbody");
|
||||||
const columns = flatColumns();
|
const columns = flatColumns();
|
||||||
|
const slopeByChainage = new Map((slope?.rows ?? []).map((row) => [row.chainage_m, row]));
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const tr = document.createElement("tr");
|
const tr = document.createElement("tr");
|
||||||
columns.forEach((column, index) => {
|
columns.forEach((column, index) => {
|
||||||
@@ -194,12 +269,25 @@ function buildBody(rows: EarthworkRow[]): HTMLTableSectionElement {
|
|||||||
if (index === 0) td.className = "b08-grid__station";
|
if (index === 0) td.className = "b08-grid__station";
|
||||||
tr.append(td);
|
tr.append(td);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const slopeRow = slopeByChainage.get(row.chainage_m);
|
||||||
|
// 사면이 원지반을 못 만난 측점은 값이 잘려 있다 — 줄에 표시를 남긴다(PLAN 8-4b).
|
||||||
|
if (slopeRow?.unclosed) tr.classList.add("is-unclosed");
|
||||||
|
for (const group of SLOPE_GROUPS) {
|
||||||
|
for (const face of group.faces) {
|
||||||
|
for (const source of [slopeRow?.lengths, slopeRow?.areas]) {
|
||||||
|
const td = document.createElement("td");
|
||||||
|
td.textContent = cell(source?.[face.key], 1);
|
||||||
|
tr.append(td);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
body.append(tr);
|
body.append(tr);
|
||||||
}
|
}
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildFoot(totals: Record<string, number>): HTMLTableSectionElement {
|
function buildFoot(totals: Record<string, number>, slope?: SlopeTable): HTMLTableSectionElement {
|
||||||
const foot = document.createElement("tfoot");
|
const foot = document.createElement("tfoot");
|
||||||
const tr = document.createElement("tr");
|
const tr = document.createElement("tr");
|
||||||
flatColumns().forEach((column, index) => {
|
flatColumns().forEach((column, index) => {
|
||||||
@@ -208,10 +296,55 @@ function buildFoot(totals: Record<string, number>): HTMLTableSectionElement {
|
|||||||
else if (column.sum) td.textContent = cell(totals[column.key], column.digits);
|
else if (column.sum) td.textContent = cell(totals[column.key], column.digits);
|
||||||
tr.append(td);
|
tr.append(td);
|
||||||
});
|
});
|
||||||
|
// 사면은 면적만 합한다 — 거리(사면길이)는 합이 뜻이 없다.
|
||||||
|
for (const group of SLOPE_GROUPS) {
|
||||||
|
for (const face of group.faces) {
|
||||||
|
tr.append(document.createElement("td"));
|
||||||
|
const td = document.createElement("td");
|
||||||
|
td.textContent = cell(slope?.totals?.[face.key], 1);
|
||||||
|
tr.append(td);
|
||||||
|
}
|
||||||
|
}
|
||||||
foot.append(tr);
|
foot.append(tr);
|
||||||
return foot;
|
return foot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 잘린 측점 경고 — 무엇이 잘렸는지 적고, 측점을 눌러 그 줄로 가게 한다. */
|
||||||
|
function buildUnclosedNotice(slope: SlopeTable, table: HTMLTableElement): HTMLElement | null {
|
||||||
|
const stations = slope.unclosed_stations ?? [];
|
||||||
|
if (!stations.length) return null;
|
||||||
|
|
||||||
|
const box = document.createElement("div");
|
||||||
|
box.className = "b08-grid__warning";
|
||||||
|
const text = document.createElement("span");
|
||||||
|
text.textContent =
|
||||||
|
`사면이 원지반을 만나지 못해 ${stations.length}개 측점에서 ` +
|
||||||
|
"사면길이·면적이 그 지점에서 잘렸습니다. 실제 값은 이보다 큽니다. " +
|
||||||
|
"같은 사유로 절·성토 면적도 잘려 있습니다.";
|
||||||
|
box.append(text);
|
||||||
|
|
||||||
|
for (const chainage of stations) {
|
||||||
|
const link = document.createElement("button");
|
||||||
|
link.type = "button";
|
||||||
|
link.className = "b08-grid__warning-station";
|
||||||
|
link.textContent = stationLabel(chainage);
|
||||||
|
link.addEventListener("click", () => {
|
||||||
|
const row = table.querySelector<HTMLElement>(
|
||||||
|
`tbody tr:nth-child(${slopeRowIndex(slope, chainage) + 1})`,
|
||||||
|
);
|
||||||
|
row?.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||||
|
row?.classList.add("is-highlighted");
|
||||||
|
window.setTimeout(() => row?.classList.remove("is-highlighted"), 1600);
|
||||||
|
});
|
||||||
|
box.append(link);
|
||||||
|
}
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slopeRowIndex(slope: SlopeTable, chainage: number): number {
|
||||||
|
return slope.rows.findIndex((row) => row.chainage_m === chainage);
|
||||||
|
}
|
||||||
|
|
||||||
/** 토적표 하나를 그린다. 넓은 표라 스스로 가로 스크롤한다. */
|
/** 토적표 하나를 그린다. 넓은 표라 스스로 가로 스크롤한다. */
|
||||||
export function renderEarthworkGrid(table: EarthworkTable): HTMLElement {
|
export function renderEarthworkGrid(table: EarthworkTable): HTMLElement {
|
||||||
const wrap = document.createElement("div");
|
const wrap = document.createElement("div");
|
||||||
@@ -226,7 +359,16 @@ export function renderEarthworkGrid(table: EarthworkTable): HTMLElement {
|
|||||||
scroller.className = "b08-grid__scroll";
|
scroller.className = "b08-grid__scroll";
|
||||||
const element = document.createElement("table");
|
const element = document.createElement("table");
|
||||||
element.className = "b08-grid__table";
|
element.className = "b08-grid__table";
|
||||||
element.append(buildHead(), buildBody(table.rows), buildFoot(table.totals));
|
element.append(
|
||||||
|
buildHead(),
|
||||||
|
buildBody(table.rows, table.slope),
|
||||||
|
buildFoot(table.totals, table.slope),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (table.slope) {
|
||||||
|
const notice = buildUnclosedNotice(table.slope, element);
|
||||||
|
if (notice) wrap.append(notice);
|
||||||
|
}
|
||||||
scroller.append(element);
|
scroller.append(element);
|
||||||
wrap.append(scroller);
|
wrap.append(scroller);
|
||||||
return wrap;
|
return wrap;
|
||||||
|
|||||||
@@ -63,6 +63,39 @@ const CSS = `
|
|||||||
font-weight: var(--font-weight-medium, 600);
|
font-weight: var(--font-weight-medium, 600);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 잘린 측점 경고 — 조용히 적게 내지 않고 눈에 보이게 한다(PLAN 8-4b). */
|
||||||
|
.b08-grid__warning {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-body);
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border-left: 3px solid var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.b08-grid__warning-station {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: pointer;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 잘린 줄은 표에서도 알아보게 왼쪽에 표시를 남긴다. */
|
||||||
|
.b08-grid__table tbody tr.is-unclosed .b08-grid__station {
|
||||||
|
border-left: 3px solid var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.b08-grid__table tbody tr.is-highlighted td {
|
||||||
|
background: var(--color-royal-amethyst, #d8ccff);
|
||||||
|
color: #1b2220;
|
||||||
|
}
|
||||||
|
|
||||||
.b08-quantity__tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--color-border); }
|
.b08-quantity__tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--color-border); }
|
||||||
|
|
||||||
.b08-quantity__tab {
|
.b08-quantity__tab {
|
||||||
|
|||||||
Reference in New Issue
Block a user