diff --git a/A00_Common/spreadsheet/spreadsheet_area_table.ts b/A00_Common/spreadsheet/spreadsheet_area_table.ts index 660642655..ec86a2d38 100644 --- a/A00_Common/spreadsheet/spreadsheet_area_table.ts +++ b/A00_Common/spreadsheet/spreadsheet_area_table.ts @@ -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) => diff --git a/A00_Common/spreadsheet/spreadsheet_cellstyle.ts b/A00_Common/spreadsheet/spreadsheet_cellstyle.ts index 05c8fc686..31a380977 100644 --- a/A00_Common/spreadsheet/spreadsheet_cellstyle.ts +++ b/A00_Common/spreadsheet/spreadsheet_cellstyle.ts @@ -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 = { - general: "left", + general: "center", left: "left", right: "right", center: "center", @@ -149,13 +149,23 @@ const HALIGN_CSS: Record = { 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 = { 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" : ""; } diff --git a/A00_Common/spreadsheet/spreadsheet_clipboard.ts b/A00_Common/spreadsheet/spreadsheet_clipboard.ts index 1eef6e5c0..9164ce8c4 100644 --- a/A00_Common/spreadsheet/spreadsheet_clipboard.ts +++ b/A00_Common/spreadsheet/spreadsheet_clipboard.ts @@ -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) => { diff --git a/A00_Common/spreadsheet/spreadsheet_editor.ts b/A00_Common/spreadsheet/spreadsheet_editor.ts index 8cf4f81a8..b7a23f3a0 100644 --- a/A00_Common/spreadsheet/spreadsheet_editor.ts +++ b/A00_Common/spreadsheet/spreadsheet_editor.ts @@ -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"; diff --git a/A00_Common/spreadsheet/spreadsheet_format_dialog.ts b/A00_Common/spreadsheet/spreadsheet_format_dialog.ts index 51a2b5769..66d199ee7 100644 --- a/A00_Common/spreadsheet/spreadsheet_format_dialog.ts +++ b/A00_Common/spreadsheet/spreadsheet_format_dialog.ts @@ -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; diff --git a/A00_Common/spreadsheet/spreadsheet_grid_cells.ts b/A00_Common/spreadsheet/spreadsheet_grid_cells.ts index a98943c5c..21160b525 100644 --- a/A00_Common/spreadsheet/spreadsheet_grid_cells.ts +++ b/A00_Common/spreadsheet/spreadsheet_grid_cells.ts @@ -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 { } const SPILL_ALIGN: Partial> = { - 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"; diff --git a/A00_Common/spreadsheet/spreadsheet_toolbar.ts b/A00_Common/spreadsheet/spreadsheet_toolbar.ts index 5c450eade..d213b4a61 100644 --- a/A00_Common/spreadsheet/spreadsheet_toolbar.ts +++ b/A00_Common/spreadsheet/spreadsheet_toolbar.ts @@ -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]; diff --git a/B05_Profile/B05_Profile_Router_Confirm.py b/B05_Profile/B05_Profile_Router_Confirm.py index 8e5117630..97668b447 100644 --- a/B05_Profile/B05_Profile_Router_Confirm.py +++ b/B05_Profile/B05_Profile_Router_Confirm.py @@ -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"]) diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index ccf09eeb6..1701a2517 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -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 => { diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts index d319eed11..c95485580 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts @@ -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; diff --git a/resources/tester/spreadsheet/spreadsheet_align_default_entry.ts b/resources/tester/spreadsheet/spreadsheet_align_default_entry.ts new file mode 100644 index 000000000..54417bd46 --- /dev/null +++ b/resources/tester/spreadsheet/spreadsheet_align_default_entry.ts @@ -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[3]) { + const text = { classList: { contains: () => true }, style: {} as Record }; + const cell = { + style: {} as Record, + 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(/]*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 })); diff --git a/resources/tester/test_200_2_b09_bill_level_bar.py b/resources/tester/test_200_2_b09_bill_level_bar.py index 743aa8eaf..3c41ac20f 100644 --- a/resources/tester/test_200_2_b09_bill_level_bar.py +++ b/resources/tester/test_200_2_b09_bill_level_bar.py @@ -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") diff --git a/resources/tester/test_238_1_save_keeps_structure_stations.py b/resources/tester/test_238_1_save_keeps_structure_stations.py new file mode 100644 index 000000000..d2ae972af --- /dev/null +++ b/resources/tester/test_238_1_save_keeps_structure_stations.py @@ -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 diff --git a/resources/tester/test_239_1_b09_total_bill_summary.py b/resources/tester/test_239_1_b09_total_bill_summary.py new file mode 100644 index 000000000..51fd8e161 --- /dev/null +++ b/resources/tester/test_239_1_b09_total_bill_summary.py @@ -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")] diff --git a/resources/tester/test_m02_area_table.py b/resources/tester/test_m02_area_table.py index 8a699f868..5826398ac 100644 --- a/resources/tester/test_m02_area_table.py +++ b/resources/tester/test_m02_area_table.py @@ -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 diff --git a/resources/tester/test_spreadsheet_align_default.py b/resources/tester/test_spreadsheet_align_default.py new file mode 100644 index 000000000..e32979c5e --- /dev/null +++ b/resources/tester/test_spreadsheet_align_default.py @@ -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