Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_3' and 'origin/sub_laptop_7' into main_laptop_1

This commit is contained in:
2026-10-10 20:06:04 +09:00
16 changed files with 535 additions and 43 deletions
@@ -8,7 +8,7 @@
* ========================================================================== */
import { colName, parseRange, rangeToA1, rangesOverlap, toA1 } from "./spreadsheet_address";
import { cellStyleAt } from "./spreadsheet_cellstyle";
import { cellStyleAt, shownHAlign } from "./spreadsheet_cellstyle";
import { createCalcEngine } from "./spreadsheet_graph";
import { DEFAULT_COL_CHARS, DEFAULT_ROW_PT } from "./spreadsheet_headers";
import { resolveName } from "./spreadsheet_names";
@@ -110,7 +110,7 @@ export function areaTable(ask: TableAsk) {
: { 글: "", 정렬: "left" as const };
const key = `${sheet.id}!${toA1(r, c)}`;
값[key] = shown.글;
const align = ALIGN[style.가로 ?? "general"] ?? (shown.정렬 as TableCell["align"]);
const align: TableCell["align"] = ALIGN[shownHAlign(style.가로)] ?? "center"; // 안 준 칸 = 가운데(237-1)
return { text: shown.글, align, ...(style.굵게 ? { bold: true } : {}), key };
};
const cells = rows.map((r) =>
@@ -139,9 +139,9 @@ function borderCss(side: BorderSide | undefined, scale: number): string {
}
}
/** 가로 정렬 → `text-align`(균등 분할 · 선택 영역 가운데는 근사: `justify` · `center`). */
/** 가로 정렬 → `text-align`(균등 분할 · 선택 영역 가운데는 근사: `justify` · `center`). 안 준 칸(일반) = 가운데(237-1). */
const HALIGN_CSS: Record<HAlign, string> = {
general: "left",
general: "center",
left: "left",
right: "right",
center: "center",
@@ -149,13 +149,23 @@ const HALIGN_CSS: Record<HAlign, string> = {
centerContinuous: "center",
};
/** 칸에 맞춤을 안 준(없음 · 일반) 보이는 가로 맞춤 = 가운데 — 화면 · 도구줄 · 대화상자 · 내보내기 공통(237-1). */
export function shownHAlign(h: HAlign | undefined): HAlign {
return !h || h === "general" ? "center" : h;
}
/** 칸에 맞춤을 안 준 보이는 세로 맞춤 = 가운데(237-1). */
export function shownVAlign(v: VAlign | undefined): VAlign {
return v ?? "center";
}
const VALIGN_CSS: Record<VAlign, string> = {
top: "flex-start",
center: "center",
bottom: "flex-end",
};
/** 칸 하나에 서식 · 겹쳐 고른 테두리를 입힘. `hAlign` 은 부른 쪽이 정함(일반 정렬은 값 종류로 갈림 — B `formatValue` 의 `정렬`).
/** 칸 하나에 서식 · 겹쳐 고른 테두리를 입힘. `hAlign` 은 부른 쪽이 정함(일반 = 가운데).
* `scale` = 확대 · 축소 배율(칸 글자 크기 · 테두리 굵기가 같이 커짐 · 기본 1). */
export function applyCellStyle(
cell: HTMLElement,
@@ -178,7 +188,7 @@ export function applyCellStyle(
s.borderLeft = borderCss(borders.왼, scale);
s.borderBottom = borderCss(borders.아래, scale);
s.borderRight = borderCss(borders.오른, scale);
s.justifyContent = VALIGN_CSS[style.세로 ?? "bottom"];
s.justifyContent = VALIGN_CSS[shownVAlign(style.세로)];
s.whiteSpace = style.줄바꿈 ? "normal" : "nowrap";
let text = cell.firstElementChild as HTMLElement | null;
@@ -187,7 +197,7 @@ export function applyCellStyle(
text.className = "aislo-cell__text";
cell.replaceChildren(text);
}
text.style.textAlign = HALIGN_CSS[hAlign];
text.style.textAlign = HALIGN_CSS[shownHAlign(hAlign)];
text.style.textAlignLast = hAlign === "distributed" ? "justify" : "";
}
@@ -14,6 +14,7 @@
import { showToast } from "@ui/ui_template_elements";
import { colName, parseA1, parseRange, toA1 } from "./spreadsheet_address";
import { shownHAlign, shownVAlign } from "./spreadsheet_cellstyle";
import { clipWholeToUsed } from "./spreadsheet_grid_cells";
import { formatValue } from "./spreadsheet_numfmt";
import { moveFormula, r1c1ToA1 } from "./spreadsheet_refshift";
@@ -70,10 +71,11 @@ function styleToCss(style: CellStyle): string {
if (deco) parts.push(`text-decoration:${deco}`);
if (style.글자색) parts.push(`color:${style.글자색}`);
if (style.채움) parts.push(`background-color:${style.채움}`);
if (style.가로 && style.가로 !== "general") {
parts.push(`text-align:${style.가로 === "centerContinuous" ? "center" : style.가로}`);
}
if (style.세로) parts.push(`vertical-align:${style.세로 === "center" ? "middle" : style.세로}`);
// 안 준 칸 = 가운데 · 가운데(237-1) — 밖(엑셀)에서도 같은 모양
const h = shownHAlign(style.가로);
parts.push(`text-align:${h === "centerContinuous" ? "center" : h}`);
const v = shownVAlign(style.세로);
parts.push(`vertical-align:${v === "center" ? "middle" : v}`);
if (style.줄바꿈) parts.push("white-space:pre-wrap");
if (style.테두리) {
(["위", "아래", "왼", "오른"] as const).forEach((side) => {
+1 -1
View File
@@ -294,7 +294,7 @@ export function createEditor(ctx: SpreadsheetContext): Editor {
ta.style.fontSize = s.크기 ? `${s.크기}pt` : "";
ta.style.fontWeight = s.굵게 ? "bold" : "";
ta.style.fontStyle = s.기울임 ? "italic" : "";
ta.style.textAlign = s.가로 === "right" || s.가로 === "center" ? s.가로 : "";
ta.style.textAlign = s.가로 === "left" || s.가로 === "right" ? s.가로 : "center";
// 글이 길면 오른쪽 · 아래로 늘림(엑셀)
ta.style.width = "0";
ta.style.height = "0";
@@ -23,7 +23,7 @@ import {
type NegStyle,
} from "./spreadsheet_format_code";
import { dialogKeys } from "./spreadsheet_actions";
import { isAutoInk } from "./spreadsheet_cellstyle";
import { isAutoInk, shownHAlign, shownVAlign } from "./spreadsheet_cellstyle";
import { formatValue } from "./spreadsheet_numfmt";
import type { BorderLine, BorderSide, CellStyle, HAlign, VAlign } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
@@ -233,7 +233,7 @@ export function openFormatDialog(ctx: SpreadsheetContext): void {
["centerContinuous", "선택 영역 가운데"],
].map(([v, t]) => el("option", { attrs: { value: v }, text: t })),
}) as HTMLSelectElement;
hSelect.value = draft.가로 ?? "general";
hSelect.value = shownHAlign(draft.가로);
hSelect.addEventListener("change", () => (draft.가로 = hSelect.value as HAlign));
const vSelect = el("select", {
@@ -243,7 +243,7 @@ export function openFormatDialog(ctx: SpreadsheetContext): void {
["bottom", "아래"],
].map(([v, t]) => el("option", { attrs: { value: v }, text: t })),
}) as HTMLSelectElement;
vSelect.value = draft.세로 ?? "bottom";
vSelect.value = shownVAlign(draft.세로);
vSelect.addEventListener("change", () => (draft.세로 = vSelect.value as VAlign));
const wrapCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement;
@@ -13,6 +13,7 @@ import {
isAutoInk,
resolveBoxBorders,
setCellText,
shownHAlign,
type ResolvedBorders,
} from "./spreadsheet_cellstyle";
import type { LineGeometry, OutlineRun } from "./spreadsheet_headers";
@@ -139,7 +140,7 @@ export function buildMergeMap(sheet: Sheet): Map<string, CellRange> {
}
const SPILL_ALIGN: Partial<Record<HAlign, SpillAlign>> = {
general: "left",
general: "center",
left: "left",
right: "right",
center: "center",
@@ -218,7 +219,7 @@ function cellDisplay(
const scalar = scalarAt(ctx, sheet, r, c);
const formatted = formatValue(scalar, code);
const isError = typeof scalar === "object" && scalar !== null && "error" in scalar;
const hAlign: HAlign = hAlign0 !== "general" ? hAlign0 : formatted.정렬;
const hAlign: HAlign = shownHAlign(hAlign0); // 안 준 칸 = 가운데(237-1)
const title = isError ? ((scalar as ErrorValue).why ?? formatted.글) : "";
const isNumeric =
!isError && scalar !== null && typeof scalar !== "string" && typeof scalar !== "boolean";
@@ -13,6 +13,7 @@
import { createButton, el } from "@ui/ui_template_elements";
import { confirmMerge } from "./spreadsheet_actions";
import { colName, rangeToA1, toA1 } from "./spreadsheet_address";
import { shownHAlign, shownVAlign } from "./spreadsheet_cellstyle";
import { cellToText, textToCell } from "./spreadsheet_editor";
import { st } from "./spreadsheet_text";
import type { BorderLine, BorderSide, CellRange, CellStyle, Command } from "./spreadsheet_types";
@@ -528,8 +529,8 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle {
strikeBtn.classList.toggle("is-on", !!style.취소선);
textColor.value = style.글자색 ?? "#000000";
fillColor.value = style.채움 ?? "#ffffff";
hAlignSelect.value = style.가로 ?? "general";
vAlignSelect.value = style.세로 ?? "bottom";
hAlignSelect.value = shownHAlign(style.가로);
vAlignSelect.value = shownVAlign(style.세로);
wrapBtn.classList.toggle("is-on", !!style.줄바꿈);
numfmtInput.value = style.형식 ?? "";
const range = ctx.selection.범위[0];
+25 -12
View File
@@ -15,7 +15,12 @@ import aiomysql
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_Profile.B05_Profile_Engine_Grade_Alignment import chainage_key
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile
from B05_Profile.B05_Profile_Engine_Sections import generate_irregular_sections
from B05_Profile.B05_Profile_Engine_Sections import (
_load_pipe_points,
_load_route_polyline,
generate_irregular_sections,
resolve_extra_stations,
)
from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions
from B05_Profile.B05_Profile_Repository import get_surface_crs_epsg
from B05_Profile.B05_Profile_Schema import RouteConfirmRequest
@@ -350,17 +355,25 @@ async def _append_irregular_cross_sections(
save_sampling_snapshot(project_root, request)
stored_options = await get_latest_section_options(connection, project_id)
crs_epsg = await get_surface_crs_epsg(connection, project_id, request.surface_model_id)
irregular_stations = await asyncio.to_thread(
generate_irregular_sections,
project_root,
str(route["route_data_path"]),
request.filter_key,
request.method,
request.smooth,
extra_stations=request.extra_stations(),
options=_section_options_from_stored(stored_options),
crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None,
)
route_data_path = str(route["route_data_path"])
def _generate() -> list[dict[str, Any]]:
# 측점 목록 = 서버 정본(관 · 구조물)에서 — 화면 목록(관 · A군)을 받아 적으면 서버가 심은
# 소단 · 옹벽 · 흙막이 · 독립 기슭막이 시작 · 종료 측점이 통째로 빠짐(238-1).
polyline = _load_route_polyline(project_root, route_data_path)
extras = resolve_extra_stations(project_root, _load_pipe_points(project_root, polyline))
return generate_irregular_sections(
project_root,
route_data_path,
request.filter_key,
request.method,
request.smooth,
extra_stations=extras,
options=_section_options_from_stored(stored_options),
crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None,
)
irregular_stations = await asyncio.to_thread(_generate)
if not irregular_stations:
return
longitudinal = await get_longitudinal_section(connection, project_id, route["id"])
+10 -6
View File
@@ -61,7 +61,7 @@ import {
} from "./B09_Estimation_UI_Tabs_Summary";
import { buildCost, buildTotal } from "./B09_Estimation_UI_Tabs_Total";
import { buildSg, buildSgList } from "./B09_Estimation_UI_Tabs_Sangeun";
import { isGroupTab, lineHint, renderGroupPanel } from "./B09_Estimation_UI_Tabs_Groups";
import { isGroupTab, lineHint, pickClass, renderGroupPanel } from "./B09_Estimation_UI_Tabs_Groups";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
@@ -262,15 +262,19 @@ export function renderB09Estimation(root: HTMLElement): void {
);
}
// 200-2 설계내역서 = 고른 줄 상자(235-1) · 229-1 레벨 단추 = 보조 탭 줄 오른쪽 · 202-1 안내 줄 = 표 아래 · Enter · F2 = 입력
// 239-1 이동해 온 줄 = 빨강 외곽선(목록표 → 탭 길 is-pick · 고른 줄 상자와 따로)
const bill = (spec as BillTable).menu ? (spec as BillTable) : null;
const table = makeTable(bill ? (i) => pickedClass(bill.rows[i]) : undefined);
const at = focus === undefined ? -1 : rowIndex(spec.rows, focus);
const jump: [number, number] | null = at >= 0 ? [at, at] : null;
const table = makeTable(
bill
? (i) => [pickedClass(bill.rows[i]), pickClass(jump, i)].filter(Boolean).join(" ") || null
: undefined,
);
const level = bill ? levelBar(table.root, bill, (i) => table.edit(i)) : null;
if (level) bars.setTools(level.tools);
panel.replaceChildren(table.root, ...(level ? [level.guide] : []), ...(hint ? [hint] : []));
if (focus !== undefined) {
const index = rowIndex(spec.rows, focus);
if (index >= 0) requestAnimationFrame(() => table.focusRow(index));
}
if (at >= 0) requestAnimationFrame(() => table.focusRow(at));
};
const apply = (next: Row, fromServer: boolean): void => {
+41 -2
View File
@@ -2,7 +2,8 @@
* B09_Estimation_UI_Tabs_Total.ts
* B09 총괄설계내역서 · 공사원가계산서 탭(PLAN 145-4) — 실무 엑셀 꼴 · 값 = `B09_Estimation_Calc_Total`.
*
* 총괄 = 가. 순공사비계(설계내역서 비목 합 · 145-3) + 제비율 줄 + 소계(나~바)
* 총괄 = 맨 위 내역 줄(239-1 · 설계내역서 레벨 1 · 2 집계 + 순공사비 계 · 누름 = 설계내역서 그 줄 빨강) +
* 가. 순공사비계(설계내역서 비목 합 · 145-3) + 제비율 줄 + 소계(나~바)
* 맨 위 = 요율 고르는 값(추정가격 · 공사기간 · 환경보전비 공종 — 누르면 도로 ↔ 그 밖)(166-1)
* 요율 = 자동(M01 조달청 제비율 · 짝 셈이 고름) · 사용자가 적으면 앞섬 · 출처 칸 누름 = 자동으로
* 고칠 칸 = 요율 · 손입력(기초액 · 이윤 보정 · 부가세 조정 · 조달수수료율) ·
@@ -84,6 +85,42 @@ const nameLevel = (rows: DataRow[]) => ({
of: (i: number) => rows[i]?.level as number | undefined,
});
/** 239-1 맨 위 내역 줄 = 설계내역서 레벨 1 · 2 폴더 줄(관급 빼고 · 레벨 1 항목도 — 합이 순공사비) ·
* 금액 = 설계내역서 셈 그대로 · 끝 = 순공사비 계 · 누름 = 설계내역서 그 줄(`bill`) */
function billRows(bill: Row | undefined): DataRow[] {
const top = (bill?.rows || []).filter(
(r: Row) => r.cost === "direct" && (r.level === 1 || (r.level === 2 && r.kind !== "item")),
);
if (!top.length) return [];
const direct: Row = bill?.totals?.direct || {};
const amounts = (r: Row) => ({
amount: r.sum ?? null,
labor: r.labor ?? null,
material: r.material ?? null,
expense: r.expense ?? null,
});
const blank = direct.blank ? ` · 빈 줄 ${direct.blank} — 아는 금액만` : "";
return [
...top.map((r: Row) => ({
key: `bill:${r.key}`,
part: "내역",
name: r.no ? `${r.no} ${r.name}` : r.name,
...amounts(r),
note: "누르면 설계내역서 그 줄로",
level: r.level,
bill: r.key,
})),
{
key: "bill_sum",
part: "내역",
name: "순공사비 계",
...amounts(direct),
note: `설계내역서 레벨 1 합(관급 제외)${blank}`,
level: 1,
},
];
}
function rateText(formula: string, rate: Num): string {
return rate === null ? `${formula}(요율 빈칸)` : formula.replace("율", `${rate}%`);
}
@@ -139,6 +176,7 @@ export const buildTotal: TabBuild = (ctx): TabTable => {
const sums: Row = t.sums || {};
const env = est.environment_kind === "road" ? "road" : "other";
const rows: DataRow[] = [
...billRows(ctx.sheets.bill),
row("in_price", "요율", "추정가격(원)", null, "일반관리비 · 이윤 · 고용보험 구간", {
hand: hand("in_price"),
}),
@@ -211,12 +249,13 @@ export const buildTotal: TabBuild = (ctx): TabTable => {
rowClass: (i) => levelClass(rows[i]),
level: nameLevel(rows),
mergeSame: ["part"],
hint: "요율 칸에 적으면 사용자 값 · 지우거나 출처 칸을 누르면 자동 값",
hint: "맨 위 내역 줄을 누르면 설계내역서 그 줄로 · 요율 칸에 적으면 사용자 값 · 지우거나 출처 칸을 누르면 자동 값",
editable: (i, key) =>
(key === "rate" && RATE_KEYS.has(String(rows[i]?.key))) ||
(key === "hand" && String(rows[i]?.key) in HAND),
onEdit: (i, key, value) => editEstimate(ctx, String(rows[i]?.key), key, value),
onCell: (i, key) => clickEstimate(ctx, String(rows[i]?.key), key, rows[i]?.from),
link: (i) => (rows[i]?.bill ? { tab: "bill", key: String(rows[i].bill) } : null),
tip: (i, key) => {
const r = rows[i];
if (!r?.rt || (key !== "rate" && key !== "candidate")) return null;
@@ -0,0 +1,65 @@
/* 맞춤 기본 = 가운데 · 가운데(237-1) 시험 진입 — vite --ssr 로 번들해 Node 로 돌림.
* 결과 = JSON {pass, fail, failures} 한 줄(`test_spreadsheet_align_default.py` 가 읽음) */
import {
applyCellStyle,
shownHAlign,
shownVAlign,
type ResolvedBorders,
} from "../../../A00_Common/spreadsheet/spreadsheet_cellstyle";
import { blockToClipboard } from "../../../A00_Common/spreadsheet/spreadsheet_clipboard";
import type { CellStyle, Workbook } from "../../../A00_Common/spreadsheet/spreadsheet_types";
let pass = 0;
const failures: string[] = [];
const eq = (name: string, got: unknown, want: unknown): void => {
if (JSON.stringify(got) === JSON.stringify(want)) pass += 1;
else failures.push(`${name}: ${JSON.stringify(got)} ≠ ${JSON.stringify(want)}`);
};
// 도구줄 · 대화상자가 보이는 값 — 안 준 칸(없음 · 일반) = 가운데 · 준 칸 그대로
eq("가로 없음", shownHAlign(undefined), "center");
eq("가로 일반", shownHAlign("general"), "center");
eq("가로 왼쪽", shownHAlign("left"), "left");
eq("가로 오른쪽", shownHAlign("right"), "right");
eq("가로 균등", shownHAlign("distributed"), "distributed");
eq("세로 없음", shownVAlign(undefined), "center");
eq("세로 위", shownVAlign("top"), "top");
eq("세로 아래", shownVAlign("bottom"), "bottom");
// 칸 CSS — 가짜 칸(DOM 없이 style 만 봄)
function paint(style: CellStyle, hAlign: Parameters<typeof applyCellStyle>[3]) {
const text = { classList: { contains: () => true }, style: {} as Record<string, string> };
const cell = {
style: {} as Record<string, string>,
firstElementChild: text,
replaceChildren: () => undefined,
};
applyCellStyle(cell as unknown as HTMLElement, style, {} as ResolvedBorders, hAlign);
return { v: cell.style.justifyContent, h: text.style.textAlign };
}
eq("칸 안 준 = 가운데 · 가운데", paint({}, "general"), { v: "center", h: "center" });
eq("칸 왼쪽 · 아래 그대로", paint({ 세로: "bottom" }, "left"), { v: "flex-end", h: "left" });
eq("칸 오른쪽 · 위 그대로", paint({ 세로: "top" }, "right"), { v: "flex-start", h: "right" });
// 내보내기(복사 HTML) — 안 준 칸 = 가운데 · 가운데 · 준 칸 그대로
const book = {
종류: "통합문서",
판: 1,
열: "rv",
서식: [{}, { 가로: "left", 세로: "top" }],
시트: [{ id: "s1", 이름: "시트1", 칸: { A1: { 값: "가" }, B1: { 값: "나", 서식: 1 } } }],
활성: "s1",
} as unknown as Workbook;
const { html } = blockToClipboard(book, "s1", { r0: 0, c0: 0, r1: 0, c1: 1 }, (_r, c) =>
c === 0 ? "가" : "나",
);
const tds = [...html.matchAll(/<td[^>]*style="([^"]*)"/g)].map((m) => m[1]);
eq("내보내기 칸 수", tds.length, 2);
eq(
"내보내기 안 준 칸",
/text-align:center/.test(tds[0]) && /vertical-align:middle/.test(tds[0]),
true,
);
eq("내보내기 준 칸", /text-align:left/.test(tds[1]) && /vertical-align:top/.test(tds[1]), true);
console.log(JSON.stringify({ pass, fail: failures.length, failures }));
@@ -196,7 +196,9 @@ def test_왼쪽_맞춤_레벨_들여쓰기_글꼴_공용_표():
def test_셸_배선():
src = (B09 / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
assert "makeTable(bill ? (i) => pickedClass(bill.rows[i]) : undefined)" in src
assert (
"? (i) => [pickedClass(bill.rows[i]), pickClass(jump, i)]" in src
) # 239-1 이동 줄 빨강 곁
assert "if (level) bars.setTools(level.tools);" in src # 229-1 단추 = 보조 탭 줄
assert "panel.replaceChildren(table.root, ...(level ? [level.guide] : [])" in src
lvl = (B09 / "B09_Estimation_UI_Tabs_Bill_Level.ts").read_text("utf-8")
@@ -0,0 +1,163 @@
"""238-1 B05 [저장]이 구조물 측점을 지우지 않음.
[저장](경로 확정 길 · `_append_irregular_cross_sections`)은 종단 비정규 측점을 통째로 갈아 끼운다.
그 목록을 화면이 보낸 「관 + A군」으로 쓰면 서버가 심어 둔 소단 · 옹벽 · 흙막이 · 독립 기슭막이
시작 · 종료 측점이 빠진다 → 서버 측점 규칙(`resolve_extra_stations`)으로 만든 목록을 씀.
- 사본: 임시 폴더 프로젝트(직선 노선 · 관 하나 + 기슭막이 · 소단 · 옹벽 · 흙막이)
- 앞: B06 [측점 만들기]와 같은 길(서버 목록으로 심음) · 뒤: 화면 목록(관만)을 실은 [저장]
- 판정: 앞뒤 구조물 측점 같음 · 소단 측점(깊이 patch 가 붙는 자리) 남음 · 관 옮김은 반영
"""
import asyncio
import json
from pathlib import Path
import numpy as np
import B05_Profile.B05_Profile_Engine_Sections as sections
import B05_Profile.B05_Profile_Router_Confirm as confirm
from B05_Profile.B05_Profile_Schema import RouteConfirmRequest
from common_util.common_util_drainage_pipes import route_signature
from common_util.common_util_route_geometry import RouteVertex
LINE = [[0.0, 0.0, 100.0], [350.0, 0.0, 100.0]]
ROUTE = "B05_Profile/route/route.geojson"
LONG = "B06_Section/longitudinal/longitudinal.json"
PIPE = {"chainage_m": 150.0, "source": "user", "options": {"pipe_diameter_mm": 800}}
REVET = {
"chainage_m": 301.0,
"source": "user",
"facility": "revetment",
"start_m": 296.0,
"end_m": 316.0,
}
STRUCTURES = [
{"type_id": "soil_guard", "chainage_m": 45.0, "start_m": 41.0, "end_m": 51.0},
{"type_id": "berm", "chainage_m": 201.0, "start_m": 196.0, "end_m": 206.0},
{"type_id": "retaining_wall", "chainage_m": 251.0, "start_m": 246.0, "end_m": 256.0},
]
class _Flat:
def sample_xy(self, xy):
count = len(np.asarray(xy, dtype=np.float64))
return np.full(count, 100.0), np.ones(count, dtype=bool)
def _write(root: Path, rel: str, data) -> None:
path = root / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
def _write_pipes(root: Path, points) -> None:
vertices = [RouteVertex(x=p[0], y=p[1], z=p[2], chainage_m=0.0) for p in LINE]
_write(
root,
"B04_PreProcess/drainage/edits/pipe_points.json",
{"route_signature": route_signature(vertices), "points": points},
)
def _project(root: Path) -> None:
_write(root, ROUTE, {"geometry": {"type": "LineString", "coordinates": LINE}})
_write_pipes(root, [PIPE, REVET])
for item in STRUCTURES:
item["placement"] = "interval"
_write(root, "B05_Profile/route/structures.json", {"revision": 1, "structures": STRUCTURES})
regular = [{"chainage_m": float(c), "kind": "regular"} for c in range(0, 351, 20)]
_write(root, LONG, {"stations": regular})
def _irregular(root: Path) -> list[tuple[str, str]]:
data = json.loads((root / LONG).read_text(encoding="utf-8"))
return [
(f"{float(s['chainage_m']):.2f}", s.get("structure"))
for s in data["stations"]
if s.get("kind") == "irregular"
]
def _patch(monkeypatch, root: Path) -> None:
async def _value(value, *_a, **_k):
return value
monkeypatch.setattr(sections, "build_surface_sampler", lambda *_a, **_k: _Flat())
monkeypatch.setattr(confirm, "get_project_storage_relative_path", lambda *_a: _value("p"))
monkeypatch.setattr(confirm, "resolve_stored_project_path", lambda _p: str(root))
monkeypatch.setattr(confirm, "get_latest_section_options", lambda *_a: _value(None))
monkeypatch.setattr(confirm, "get_surface_crs_epsg", lambda *_a: _value(None))
monkeypatch.setattr(
confirm,
"get_longitudinal_section",
lambda *_a: _value({"longitudinal_file_path": LONG}),
)
def _plant_like_b06(root: Path) -> None:
"""B06 [측점 만들기]와 같은 길 — 서버 목록으로 뜨고 종단에 병합."""
pipes = sections._load_pipe_points(root, LINE)
stations = sections.generate_irregular_sections(
root,
ROUTE,
"csf",
"tin",
False,
extra_stations=sections.resolve_extra_stations(root, pipes),
)
confirm._merge_irregular_into_longitudinal(root, LONG, stations)
def _save(root: Path, screen: list[tuple[float, str]]) -> None:
"""B05 [저장] — 화면은 관 · A군 목록만 실어 보냄."""
request = RouteConfirmRequest(
filter_key="csf",
method="tin",
surface_model_id=1,
irregular_stations=[{"chainage_m": c, "structure": s} for c, s in screen],
)
assert request.can_regenerate()
asyncio.run(
confirm._append_irregular_cross_sections(
None, "p", {"id": 1, "route_data_path": ROUTE}, request
)
)
def test_save_keeps_server_planted_stations(tmp_path, monkeypatch):
_project(tmp_path)
_patch(monkeypatch, tmp_path)
_plant_like_b06(tmp_path)
before = _irregular(tmp_path)
labels = {label for _key, label in before}
assert {"소단", "옹벽", "흙막이", "기슭막이"} <= labels
assert [k for k, label in before if label == "기슭막이"] == ["296.00", "301.00", "316.00"]
_save(tmp_path, [(150.0, "파형강관 D800")])
assert _irregular(tmp_path) == before
# 소단 깊이(194 · 측점 patch)가 붙는 자리 = 소단 측점 — 그대로 섬
assert [k for k, label in _irregular(tmp_path) if label == "소단"] == [
"196.00",
"201.00",
"206.00",
]
def test_save_follows_moved_pipe(tmp_path, monkeypatch):
_project(tmp_path)
_patch(monkeypatch, tmp_path)
_plant_like_b06(tmp_path)
before = _irregular(tmp_path)
# 관 150 → 165 (관 초안이 [저장] 차례상 먼저 정본에 들어감 · design_save.ts)
_write_pipes(tmp_path, [{**PIPE, "chainage_m": 165.0}, REVET])
_save(tmp_path, [(165.0, "파형강관 D800")])
after = _irregular(tmp_path)
expected = sorted(
[item for item in before if item[0] != "150.00"] + [("165.00", "파형강관 D800")],
key=lambda item: float(item[0]),
)
assert after == expected
@@ -0,0 +1,117 @@
"""239-1 B09 총괄설계내역서 맨 위 = 설계내역서 레벨 1 · 2 집계 · 누름 = 설계내역서 그 줄 빨강.
- 맨 위 내역 줄 = 설계내역서 레벨 1 · 2 폴더 줄(관급 빼고 · 레벨 1 항목도) · 설계내역서 차례 · 레벨 그대로
- 금액(합계 · 노무 · 재료 · 경비) = 설계내역서 그 줄 금액(Calc_Bill) · 끝 「순공사비 계」 = 총괄 가. 순공사비계
- 줄 누름 = link {tab: bill, key} → Shell 이 설계내역서 그 줄에 is-pick(빨강 외곽선 · 고른 줄 상자와 따로)
- 아래 원가계산 줄 = 그대로(요율 입력 줄부터 같은 차례)
"""
import json
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from test_182_b09_bill_level import BOOKS, HAND, INPUTS, USER # noqa: E402
from test_184_3_b09_bill_menu import _node, _ts # noqa: E402
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: E402
from B09_Estimation.B09_Estimation_Calc_Total import compute_total # noqa: E402
SRC = ["Calc_Base", "Calc_Total", "UI_Tabs", "UI_Tabs_Total"]
#: Shell 이 부르는 줄 class(목록표 → 탭 길 · 181-2) — 묶음 탭 파일에서 그 함수만
GROUPS = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Groups.ts").read_text("utf-8")
PICK = re.search(r"export function pickClass\(.*?\n}\n", GROUPS, re.DOTALL).group(0)
PARTS = ["sum", "labor", "material", "expense"]
#: 빈 줄 없는 판(연결 없는 earth:5 · hand:c 뺌) — 총괄 순공사비계가 값을 받음
FULL_INPUTS = {**INPUTS, "bill": {"items": [i for i in INPUTS["bill"]["items"] if i["ref"]]}}
FULL_USER = {"bill": {"rows": [h for h in HAND if h["key"] != "hand:c"]}}
JS = """
const sheets = __SHEETS__;
const ctx = { sheets, payload: {}, user: { estimate: {} }, setUser: () => {} };
const spec = buildTotal(ctx);
const plain = buildTotal({ ...ctx, sheets: { total: sheets.total } });
console.log(JSON.stringify({
rows: spec.rows.map((r, i) => ({ ...r, link: spec.link(i, "name"), cls: spec.rowClass(i),
edit: ["amount", "rate", "hand"].some((k) => spec.editable(i, k)) })),
plain: plain.rows.map((r) => r.key),
pick: [pickClass([3, 3], 2), pickClass([3, 3], 3), pickClass(null, 3)],
}));
"""
def _run(inputs, user):
bill = compute_estimation(inputs, user, None, BOOKS)["bill"]
total = compute_total(bill["sums"], {})
sheets = {"bill": bill, "total": total}
out = json.loads(_node(_ts(SRC) + PICK + JS.replace("__SHEETS__", json.dumps(sheets))))
return bill, total, out
def test_맨_위_줄_레벨_1_2_폴더_차례_금액_같음():
bill, _, out = _run(INPUTS, USER)
want = [
r
for r in bill["rows"]
if r["cost"] == "direct" and (r["level"] == 1 or (r["level"] == 2 and r["kind"] != "item"))
]
top = [r for r in out["rows"] if r["key"].startswith("bill:")]
assert [r["key"] for r in top] == [f"bill:{r['key']}" for r in want]
assert {r["level"] for r in top} == {1, 2}
assert "bill:sec:G" not in {r["key"] for r in top} # 관급 = 순공사비 밖
assert "bill:haul:1" not in {r["key"] for r in top} # 레벨 2 항목(파일) 빠짐
for got, src in zip(top, want):
assert got["name"] == f"{src['no']} {src['name']}"
assert [got["amount"], got["labor"], got["material"], got["expense"]] == [
src[p] for p in PARTS
]
assert got["cls"] == ("is-title" if src["level"] == 1 else None)
assert got["edit"] is False
# 맨 위부터 내역 줄 → 순공사비 계 → 요율 입력 줄
keys = [r["key"] for r in out["rows"]]
assert keys[: len(top) + 2] == [r["key"] for r in top] + ["bill_sum", "in_price"]
def test_합_순공사비_레벨1_합_총괄_같음():
bill, total, out = _run(FULL_INPUTS, FULL_USER)
assert bill["totals"]["direct"]["blank"] == 0
rows = {r["key"]: r for r in out["rows"]}
s = rows["bill_sum"]
lv1 = [r for r in out["rows"] if r["key"].startswith("bill:") and r["level"] == 1]
for col, p in zip(["amount", "labor", "material", "expense"], PARTS):
assert s[col] == bill["totals"]["direct"][p]
assert s[col] == sum(r[col] for r in lv1)
assert s["amount"] == rows["direct"]["amount"] == total["sums"]["direct"] > 0
assert s["level"] == 1 and s["link"] is None
def test_빈_줄_있으면_계_안내():
bill, _, out = _run(INPUTS, USER)
s = next(r for r in out["rows"] if r["key"] == "bill_sum")
assert bill["totals"]["direct"]["blank"] == 2
assert s["amount"] == bill["totals"]["direct"]["sum"] and "빈 줄 2" in s["note"]
def test_누름_설계내역서_그_줄_빨강():
bill, _, out = _run(INPUTS, USER)
keys = {r["key"] for r in bill["rows"]}
for r in out["rows"]:
if r["key"].startswith("bill:"):
assert r["link"] == {"tab": "bill", "key": r["key"][5:]} and r["link"]["key"] in keys
else:
assert r["link"] is None # 원가계산 줄 = 이동 없음(그대로)
assert out["pick"] == [None, "is-pick is-pick-top is-pick-end", None]
shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
assert "const jump: [number, number] | null = at >= 0 ? [at, at] : null;" in shell
assert "[pickedClass(bill.rows[i]), pickClass(jump, i)]" in shell
def test_설계내역서_없으면_원가계산_줄_그대로():
_, _, out = _run(INPUTS, USER)
assert out["plain"][0] == "in_price"
assert out["plain"] == [r["key"] for r in out["rows"] if not r["key"].startswith("bill")]
+21 -3
View File
@@ -33,6 +33,7 @@ SHEET = {
"A2": {"값": "합"},
"B2": {"식": "B1*2", "서식": 1},
"A3": {"값": "숨김행"},
"F1": {"값": "왼쪽", "서식": 3},
"C1": {"값": "x"},
},
"병합": ["A1:A2", "C1:E5"],
@@ -43,7 +44,7 @@ BOOK = {
"종류": "통합문서",
"판": 1,
"열": "rv",
"서식": [{}, {"형식": "#,##0.00", "굵게": True}, {"가로": "center"}],
"서식": [{}, {"형식": "#,##0.00", "굵게": True}, {"가로": "center"}, {"가로": "left"}],
"시트": [SHEET],
"활성": "s1",
}
@@ -56,7 +57,12 @@ def _one(**ask) -> dict:
def test_표시_글_형식_굵게_정렬_칸_key():
got = _one(시트="면적산출", 범위="A1:B2")
cells = got["표"]["shapeData"]["cells"]
assert cells[0][1] == {"text": "1,234.50", "align": "right", "bold": True, "key": "s1!B1"}
assert cells[0][1] == {
"text": "1,234.50",
"align": "center",
"bold": True,
"key": "s1!B1",
} # 안 준 수 칸 = 가운데(237-1)
assert cells[1][1]["text"] == "2,469.00" # 식 칸 = 풀이 값에 형식
assert cells[0][0]["align"] == "center" # 가로 서식
assert (got["시트"], got["범위"]) == ("s1", "A1:B2")
@@ -64,6 +70,14 @@ def test_표시_글_형식_굵게_정렬_칸_key():
assert got["표"]["shapeData"]["style"] == {"fontSize": 2.5, "padding": 1, "연결": ""}
def test_맞춤_안_준_칸은_가운데_준_칸은_그대로_237_1():
cells = _one(시트="s1", 범위="A1:F1")["표"]["shapeData"]["cells"][0]
by_key = {c["key"]: c["align"] for c in cells if c}
assert by_key["s1!A1"] == "center" # 가로 center 를 준 칸
assert by_key["s1!B1"] == "center" # 안 준 수 칸 (옛: right)
assert by_key["s1!F1"] == "left" # 가로 left 를 준 칸 그대로
def test_병합_잘라냄_숨긴_행열_뺌_빈_칸():
shape = _one(시트="s1", 범위="A1:D4", 글자크기=5)["표"]["shapeData"]
cells = shape["cells"]
@@ -71,7 +85,11 @@ def test_병합_잘라냄_숨긴_행열_뺌_빈_칸():
assert cells[0][0]["rowSpan"] == 2 and cells[1][0] is None
assert cells[0][2]["rowSpan"] == 3 and "colSpan" not in cells[0][2] # C1:E5 → C1:C4(D 숨김)
assert cells[1][2] is None and cells[2][2] is None
assert cells[2][0] == {"text": "", "align": "left", "key": "s1!A4"} # 빈 칸
assert cells[2][0] == {
"text": "",
"align": "center",
"key": "s1!A4",
} # 빈 칸 · 안 준 칸 = 가운데
assert shape["style"]["fontSize"] == 5
assert shape["columnWidths"][1] == pytest.approx(12 * 5 * 0.7) # 열 폭 비율
assert shape["rowHeights"] == [pytest.approx(5 * 1.8)] * 3
@@ -0,0 +1,57 @@
"""맞춤 기본 = 가운데 · 가운데(237-1) — 안 준 칸 · 준 칸 · 도구줄 기본 표시 · 내보내기(복사 HTML).
`spreadsheet_align_default_entry.ts` 를 vite `--ssr` 로 번들해 Node 로 돌림. 영역 표 맞춤은
`test_m02_area_table.py`. 화면(도구줄 · 칸 · 편집창)은 사용자가 확인.
"""
from __future__ import annotations
import json
import shutil
import subprocess
import tempfile
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
ENTRY = ROOT / "resources" / "tester" / "spreadsheet" / "spreadsheet_align_default_entry.ts"
VITE = ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def test_맞춤_기본_가운데_237_1():
with tempfile.TemporaryDirectory(prefix="spreadsheet_align_default_") as outdir:
entry_rel = "../" + str(ENTRY.relative_to(ROOT)).replace("\\", "/")
build = subprocess.run(
[
"node",
str(VITE),
"build",
"--configLoader",
"runner",
"--ssr",
entry_rel,
"--outDir",
outdir,
],
cwd=str(ROOT),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=120,
)
assert build.returncode == 0, f"vite ssr 번들 실패:\n{build.stdout}\n{build.stderr}"
run = subprocess.run(
["node", str(Path(outdir) / "spreadsheet_align_default_entry.js")],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=60,
)
assert run.stdout.strip(), run.stderr
result = json.loads(run.stdout)
assert result["fail"] == 0, result["failures"]
assert result["pass"] == 14