diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 1a3396462..72e2f5a61 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -64,9 +64,11 @@ export const STATE_REGISTRY = { "m02-card-pills": { bucket: "pref", scope: "global" }, /** M01 단가산출 로직 상세 오른쪽 — 열린 패널 하나(flow · source · none) */ "m01-logic-panel": { bucket: "pref", scope: "global" }, - /** B09 묶음 탭(171-3) 보기 `{탭: cards · table}` · 가로 열 수 `{탭: 1~4}` */ + /** B09 묶음 탭(171-3) 보기 `cards · table`(304-1 셋 탭 한 값) · 가로 열 수 `{탭: 1~4}` */ "b09-group-view": { bucket: "pref", scope: "global" }, "b09-group-cols": { bucket: "pref", scope: "global" }, + /** B09 행 누름(303-1) `popup · link` */ + "b09-link-mode": { bucket: "pref", scope: "global" }, /** B05 계획노선 편집 창 — 보던 화면(회전 · 가운데 · 배율). 프로젝트마다 따로 */ "routeedit-view": { bucket: "pref", scope: "project" }, /** B05 계획노선 편집 창 — 시점 · 종점 · 교각점 고정. [저장] 대상이 아니라 draft 에 안 둠 */ diff --git a/A00_Common/design/design_structure_ford_calc.ts b/A00_Common/design/design_structure_ford_calc.ts index 474f37a92..0cfa6c52d 100644 --- a/A00_Common/design/design_structure_ford_calc.ts +++ b/A00_Common/design/design_structure_ford_calc.ts @@ -15,10 +15,8 @@ * (같은 풀이 · n · S 를 받음) · 거울 시험 test_73_11_b08_hydro_box_ford. * ========================================================================== */ -import { FORD_PAVEMENT_DEFAULT_WIDTH_M } from "@config/config_frontend"; import { fordSection } from "../../B05_Profile/B05_Profile_UI_Drainage_Facility_Fields"; import type { SpecForm } from "./design_structure_form"; -import { CHUTE_ITEMS } from "./design_structure_guard"; import type { SpecContainer, SpecItem } from "./design_structure_spec"; /** 월류 폭(m) → 필요 월류 높이(m) · 0.01 m 올림. 유량 · 폭이 없으면 null. */ @@ -81,39 +79,3 @@ export function fordHeightHint(designFlowM3s: number | null, widthM: number): st if (need !== null) return `필요 수심 ${need.toFixed(2)} 이상`; return designFlowM3s ? "월류 폭을 넣으면 셈" : "설계유량 없음 — 수기 입력"; } - -/** 도수로 · 돌붙임 면적(㎡ · 287-1) = 폭 × 길이 · 0.01 반올림. */ -export function chuteAreaM2(widthM: number, lengthM: number): number { - return Math.round(widthM * lengthM * 100) / 100; -} - -/** - * 물넘이 포장 · 세월교 도수로/돌붙임 길이 ↔ 면적(287-1 · 294-4) — 길이를 넣으면 면적 = 폭 × 길이(폭 = 띠가 - * 도로 방향으로 걸치는 월류 폭 · 비면 물넘이 기본 폭 · 배관 · 박스는 폭 정의 없어 자동 안 함) · 길이를 비우면 면적 = 칸 기본값 · 월류 폭을 바꾸면 길이가 있을 때만 - * 다시 셈. 길이는 그림(사면 따라 그 길이) · 면적은 수량(B08 돌붙임)이 씀. - */ -export function syncChuteArea( - form: Pick, - c: SpecContainer, - item: SpecItem, - /** 도수로 상자 · 월류 폭 상자 — 물넘이 `chute` · `slope` · 세월교 `chute_add` · `ford`(294-4). */ - chuteBox = "chute", - widthBox = "slope", -): void { - const byLength = c.id === chuteBox && item.id === "length_m"; - if (!byLength && !(c.id === widthBox && item.id === "width")) return; - const chute = form.state()[chuteBox]?.[0]; - if (!chute) return; - const length = Number(chute.length_m ?? ""); - if (!(chute.length_m ?? "").trim() || !(length > 0)) { - if (byLength) - form.setValue(chuteBox, "area", CHUTE_ITEMS.find((i) => i.id === "area")?.default ?? ""); - return; - } - const width = Number(form.state()[widthBox]?.[0]?.width ?? ""); - form.setValue( - chuteBox, - "area", - String(chuteAreaM2(width > 0 ? width : FORD_PAVEMENT_DEFAULT_WIDTH_M, length)), - ); -} diff --git a/A00_Common/main.ts b/A00_Common/main.ts index 56ae1a236..571f02c10 100644 --- a/A00_Common/main.ts +++ b/A00_Common/main.ts @@ -20,6 +20,7 @@ import { renderCurrentRoute, ensureInitialHash, setLeaveGuard, + draftRoute, showWithinPage, takeLeaveGuard, } from "./router"; @@ -70,7 +71,8 @@ function bootstrap(): void { shown = currentRoute(); shownHash = window.location.hash; if (within) return; - const showFooter = !FOOTERLESS_ROUTES.includes(currentRoute()); + const showFooter = + !FOOTERLESS_ROUTES.includes(currentRoute()) && !draftRoute(currentRoute())?.footerless; const outlet = renderShell(appRoot, showFooter); // 헤더/푸터(로그인·언어 반영) 갱신 void renderCurrentRoute(outlet); // 아울렛에 페이지 렌더 }; diff --git a/A00_Common/router.ts b/A00_Common/router.ts index f41d74244..c5680ec2d 100644 --- a/A00_Common/router.ts +++ b/A00_Common/router.ts @@ -74,6 +74,29 @@ const routeTable: Partial Promise>> = { (await import("../M02_MasterTemplete/M02_MasterTemplete_UI_Page")).renderM02MasterTemplate, }; +/** 초안 길(302-1) — 페이지 폴더 `draft/*_Route.ts` 의 `ROUTE` 선언 · 폴더째 지우면 길도 없음. + * 정적 표에 이름을 박지 않음 — 보호 · 단계 화면(로그인 · 회사) · 프로젝트 · 꼬리말 없음도 선언에서. */ +export interface DraftRoute { + path: string; + load: () => Promise; + /** 로그인 · 회사 필요(B 단계 화면) — 로그인만이면 protected */ + workflow?: boolean; + protected?: boolean; + /** 현재 프로젝트로 여는 화면(지워진 프로젝트 안내) */ + project?: boolean; + footerless?: boolean; +} + +const DRAFT_ROUTES = new Map( + Object.values(import.meta.glob<{ ROUTE: DraftRoute }>("../*/draft/*_Route.ts", { eager: true })) + .map((m) => m.ROUTE) + .filter(Boolean) + .map((r) => [r.path, r] as const), +); + +/** 초안 길 선언(없으면 undefined) */ +export const draftRoute = (route: string): DraftRoute | undefined => DRAFT_ROUTES.get(route); + /** 페이지가 떠나기 전에 묻는 자리 — false 를 돌려주면 이동을 취소(주소도 되돌림). 이동이 끝나면 비워짐. */ let leaveGuard: (() => Promise) | null = null; export const setLeaveGuard = (guard: (() => Promise) | null): void => { @@ -159,7 +182,9 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise { mounted?.dispose(); mounted = null; - if (PROTECTED_ROUTES.includes(route) && !(await isAuthenticated())) { + const draft = draftRoute(route); + const guarded = PROTECTED_ROUTES.includes(route) || !!(draft?.protected || draft?.workflow); + if (guarded && !(await isAuthenticated())) { navigateTo(ROUTES.A06_LOGIN); return; } @@ -178,7 +203,7 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise { ROUTES.B11_LOADING, ]; - if (workflowRoutes.includes(route)) { + if (workflowRoutes.includes(route) || draft?.workflow) { const { fetchSessionUser } = await import("../A06_Login/A06_Login_Api_Fetch"); try { const user = await fetchSessionUser(); @@ -197,7 +222,7 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise { } // 기억한 현재 프로젝트가 지워졌으면 페이지 대신 안내 — 모든 B 단계 화면이 여기 한 곳을 지난다. - if (PROJECT_ROUTES.includes(route)) { + if (PROJECT_ROUTES.includes(route) || draft?.project) { const { dropGoneProject } = await import("./b_workflow_nav"); if (await dropGoneProject()) { if (seq !== renderSeq) return; @@ -208,7 +233,7 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise { } } - const loader = routeTable[route]; + const loader = routeTable[route] ?? draft?.load; if (!loader) { renderPlaceholder(outlet, route); return; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index a9a65e47b..7a1b435ea 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -64,7 +64,7 @@ import { boxOptionsIn, } from "../A00_Common/design/design_structure_box"; import { FORD_SPEC, withLegacyGuards } from "../A00_Common/design/design_structure_ford"; -import { overflowSync, syncChuteArea } from "../A00_Common/design/design_structure_ford_calc"; +import { overflowSync } from "../A00_Common/design/design_structure_ford_calc"; import { pipeDrawn, pipeExtraCount, @@ -384,10 +384,7 @@ export function createFacilityOptionsForm( ); // 유출 흙막이 단 수 = 흙막이 상자 수(관보호공과 같은 방향 · 「+ 흙막이」 없음 · 90-1) const fordForm: SpecForm = createSpecForm(specLinks, { - onChange: (c, item) => ( - syncChuteArea(fordForm, c, item, "chute_add", "ford"), - fordFlowBox.onChange(c, item) - ), + onChange: fordFlowBox.onChange, fields: (c, values) => [...guardFields(c, values), ...fordFlowBox.fields(c)], count: guardSoilCount( () => chainage, @@ -477,7 +474,7 @@ export function createFacilityOptionsForm( emit, ); const paveForm: SpecForm = createSpecForm(specLinks, { - onChange: (c, item) => (syncChuteArea(paveForm, c, item), paveFlowBox.onChange(c, item)), + onChange: paveFlowBox.onChange, fields: paveFlowBox.fields, }); diff --git a/B07_DesignDetail/B07_DesignDetail_Cross_Extent_Node.ts b/B07_DesignDetail/B07_DesignDetail_Cross_Extent_Node.ts index 8ed1dab83..9a4917a1c 100644 --- a/B07_DesignDetail/B07_DesignDetail_Cross_Extent_Node.ts +++ b/B07_DesignDetail/B07_DesignDetail_Cross_Extent_Node.ts @@ -14,163 +14,13 @@ import { readFileSync, writeFileSync } from "node:fs"; import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; +import "./B07_DesignDetail_Node_Dom"; // 최소 DOM(globalThis.document) — 그리기 코드보다 먼저 import { overlayEntities, type Entity } from "./B07_DesignDetail_Cross_Overlay"; /** 화면과 같은 종이 축척(1/100 → 1 m = 10 mm) — 공선점 허용 오차가 종이 mm 라 같아야 한다. */ const MM_PER_M = 10; const OPEN_FRAME = [-1e12, -1e12, 1e12, 1e12]; -/* ── 최소 DOM: 그리기 코드가 쓰는 만큼만 ─────────────────────────────────── */ - -type Child = FakeElement | string; - -/** 선택자 하나(`tag`, `tag[attr]`)가 이 요소에 맞나 — 그리기·수확이 쓰는 꼴만 안다. */ -function matches(element: FakeElement, selectors: string): boolean { - return selectors.split(",").some((raw) => { - const found = /^([\w-]*)(?:\[([\w-]+)\])?$/.exec(raw.trim()); - if (!found) return false; - const [, tag, attribute] = found; - if (tag && element.tagName !== tag) return false; - return !attribute || element.attributes.has(attribute); - }); -} - -class FakeElement { - readonly attributes = new Map(); - readonly childNodes: Child[] = []; - parentNode: FakeElement | null = null; - readonly dataset: Record = {}; - readonly style: Record = { setProperty: () => undefined }; - readonly classList = { - add: (...names: string[]) => this.setClasses([...this.classes(), ...names]), - remove: (...names: string[]) => - this.setClasses(this.classes().filter((name) => !names.includes(name))), - toggle: (name: string, force?: boolean) => { - const on = force ?? !this.classes().includes(name); - this.setClasses(on ? [...this.classes(), name] : this.classes().filter((c) => c !== name)); - return on; - }, - contains: (name: string) => this.classes().includes(name), - }; - - constructor(readonly tagName: string) {} - - get ownerDocument(): typeof fakeDocument { - return fakeDocument; - } - get parentElement(): FakeElement | null { - return this.parentNode; - } - get children(): FakeElement[] { - return this.childNodes.filter((child): child is FakeElement => typeof child !== "string"); - } - get firstChild(): Child | null { - return this.childNodes[0] ?? null; - } - get lastChild(): Child | null { - return this.childNodes[this.childNodes.length - 1] ?? null; - } - get textContent(): string { - return this.childNodes - .map((child) => (typeof child === "string" ? child : child.textContent)) - .join(""); - } - set textContent(value: string | null) { - this.replaceChildren(...(value ? [String(value)] : [])); - } - - private classes(): string[] { - return (this.attributes.get("class") ?? "").split(/\s+/).filter(Boolean); - } - private setClasses(names: string[]): void { - this.attributes.set("class", [...new Set(names)].join(" ")); - } - private adopt(child: Child): Child { - if (typeof child !== "string") { - child.parentNode?.removeChild(child); - child.parentNode = this; - } - return child; - } - - setAttribute(name: string, value: unknown): void { - this.attributes.set(name, String(value)); - } - setAttributeNS(_ns: string | null, name: string, value: unknown): void { - this.setAttribute(name, value); - } - getAttribute(name: string): string | null { - return this.attributes.get(name) ?? null; - } - hasAttribute(name: string): boolean { - return this.attributes.has(name); - } - removeAttribute(name: string): void { - this.attributes.delete(name); - } - append(...nodes: Child[]): void { - for (const node of nodes) this.childNodes.push(this.adopt(node)); - } - appendChild(node: T): T { - this.append(node); - return node; - } - prepend(...nodes: Child[]): void { - this.childNodes.unshift(...nodes.map((node) => this.adopt(node))); - } - insertBefore(node: T, reference: Child | null): T { - const index = reference ? this.childNodes.indexOf(reference) : -1; - if (index < 0) return this.appendChild(node); - this.childNodes.splice(index, 0, this.adopt(node)); - return node; - } - removeChild(node: T): T { - const index = this.childNodes.indexOf(node); - if (index >= 0) this.childNodes.splice(index, 1); - if (typeof node !== "string") node.parentNode = null; - return node; - } - replaceChildren(...nodes: Child[]): void { - for (const child of [...this.childNodes]) this.removeChild(child); - this.append(...nodes); - } - remove(): void { - this.parentNode?.removeChild(this); - } - closest(selectors: string): FakeElement | null { - for (let node: FakeElement | null = this; node; node = node.parentNode) { - if (matches(node, selectors)) return node; - } - return null; - } - querySelectorAll(selectors: string): FakeElement[] { - const found: FakeElement[] = []; - const walk = (node: FakeElement): void => { - for (const child of node.children) { - if (matches(child, selectors)) found.push(child); - walk(child); - } - }; - walk(this); - return found; - } - querySelector(selectors: string): FakeElement | null { - return this.querySelectorAll(selectors)[0] ?? null; - } - addEventListener(): void {} - removeEventListener(): void {} -} - -const fakeDocument = { - createElementNS: (_ns: string, tag: string) => new FakeElement(tag), - createElement: (tag: string) => new FakeElement(tag), - createTextNode: (text: string) => text, - // 오프스크린 SVG 는 문서에 안 붙어 있다 — 브라우저도 여기서 null 을 낸다(클립 해칭은 안 자름). - getElementById: () => null, -}; - -(globalThis as Record).document = fakeDocument; - /* ── 범위 재기 ─────────────────────────────────────────────────────────── */ /** diff --git a/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts b/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts index dbce9f48d..8fd7616f8 100644 --- a/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts +++ b/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts @@ -552,3 +552,85 @@ export function overlayEntities( ...harvest(pipeSvg, PIPE_STYLE, frame), ]; } + +/** 서버가 도면에 실어 보내는 측점별 실좌표(m) → 종이(mm) 변환값. */ +export interface CrossPlacement { + chainage_m: number; + ox: number; + oy: number; + dy: number; + mm_per_m: number; + x0: number; + x1: number; + /** 블록 테두리(종이 mm) [x0, y0, x1, y1] — 그림을 이 안으로 자른다. */ + frame?: number[]; + /** 측점 한 벌 묶음(180-1) — 덧그림도 같은 묶음으로 함께 골라짐. */ + group_id?: string; + /** 측점 도면층(212-2) — 덧그림도 그 층에. */ + layer_id?: string; + /** 갈아 끼울 서버 설계선 id(212-2) — 측점 층이면 층으로는 못 가림. */ + design_ids?: string[]; +} + +/** 개체(자식 선까지)를 측점 층으로(212-2). */ +function moveToLayer(entity: Entity, layerId: string): void { + entity.layerId = layerId; + for (const child of (entity.children as Entity[] | undefined) ?? []) moveToLayer(child, layerId); +} + +/** 측점과 도면 배치를 같은 자리로 볼 허용 오차(m). 정본이 누가거리를 0.01m로 끊어 쓴다. */ +const CHAINAGE_TOLERANCE_M = 0.02; + +/** + * 도면에 구조물 엔티티를 얹는다 (제자리 수정) — 화면(`_UI_Cad_Structures`)과 인쇄 Node(302-2)가 같이 씀. + * 배치 메타가 없으면 아무것도 하지 않는다 — 구조물이 빠져도 도면 자체는 열려야 한다. + */ +export function placeStructureEntities( + sections: CrossSection[], + drawing: { entities: Record[]; cross_placements?: CrossPlacement[] }, +): number { + const placements = drawing.cross_placements ?? []; + let added = 0; + const fresh: Entity[] = []; + const replaced: number[][] = []; + const designIds = new Set(); + for (const placement of placements) { + const section = sections.find( + (item) => Math.abs(item.chainage_m - placement.chainage_m) <= CHAINAGE_TOLERANCE_M, + ); + const frame = placement.frame; + if (!section || !frame) continue; + const x = (offset: number): number => offset * placement.mm_per_m + placement.ox; + const y = (elevation: number): number => + -((elevation - placement.dy) * placement.mm_per_m + placement.oy); + let entities: Entity[]; + try { + const drawn = overlayEntities(section, sections, x, y, frame); + if (!drawn) continue; + entities = drawn; + } catch { + continue; // 한 측점의 기하 실패가 도면 전체를 막으면 안 된다. + } + if (!entities.length) continue; + if (placement.group_id) for (const entity of entities) entity.groupId = placement.group_id; + if (placement.layer_id) for (const entity of entities) moveToLayer(entity, placement.layer_id); + for (const id of placement.design_ids ?? []) designIds.add(id); + // 이 블록 설계선은 우리가 다시 그렸다 — 서버가 낸 트림 전 설계선은 걷어낸다. + replaced.push(frame); + fresh.push(...entities); + added += entities.length; + } + if (replaced.length) { + // 걷어내기는 새 엔티티를 넣기 **전에** 한다 — 뒤에 하면 우리 것까지 같이 지운다. + drawing.entities = drawing.entities.filter((entity) => { + if (entity.layerId !== DESIGN_LAYER_ID && !designIds.has(entity.id as string)) return true; + const box = entityBox(entity); + if (!box) return true; + return !replaced.some( + ([fx0, fy0, fx1, fy1]) => box[0] >= fx0 && box[2] <= fx1 && box[1] >= fy0 && box[3] <= fy1, + ); + }); + } + drawing.entities.push(...fresh); + return added; +} diff --git a/B07_DesignDetail/B07_DesignDetail_Node_Dom.ts b/B07_DesignDetail/B07_DesignDetail_Node_Dom.ts new file mode 100644 index 000000000..c9183de79 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Node_Dom.ts @@ -0,0 +1,161 @@ +/* ============================================================================= + * B07_DesignDetail_Node_Dom.ts + * 서버 Node 진입점이 쓰는 **최소 DOM** — 덧그림 그리기 코드가 오프스크린 SVG 에 쓰는 만큼만. + * 불러오기만 하면 `globalThis.document` 가 선다(`_Cross_Extent_Node` · 인쇄 302-2 `B09_Draft_Print_Drawings_Node`). + * ========================================================================== */ + +/* ── 최소 DOM: 그리기 코드가 쓰는 만큼만 ─────────────────────────────────── */ + +type Child = FakeElement | string; + +/** 선택자 하나(`tag`, `tag[attr]`)가 이 요소에 맞나 — 그리기·수확이 쓰는 꼴만 안다. */ +function matches(element: FakeElement, selectors: string): boolean { + return selectors.split(",").some((raw) => { + const found = /^([\w-]*)(?:\[([\w-]+)\])?$/.exec(raw.trim()); + if (!found) return false; + const [, tag, attribute] = found; + if (tag && element.tagName !== tag) return false; + return !attribute || element.attributes.has(attribute); + }); +} + +class FakeElement { + readonly attributes = new Map(); + readonly childNodes: Child[] = []; + parentNode: FakeElement | null = null; + readonly dataset: Record = {}; + readonly style: Record = { setProperty: () => undefined }; + readonly classList = { + add: (...names: string[]) => this.setClasses([...this.classes(), ...names]), + remove: (...names: string[]) => + this.setClasses(this.classes().filter((name) => !names.includes(name))), + toggle: (name: string, force?: boolean) => { + const on = force ?? !this.classes().includes(name); + this.setClasses(on ? [...this.classes(), name] : this.classes().filter((c) => c !== name)); + return on; + }, + contains: (name: string) => this.classes().includes(name), + }; + + constructor(readonly tagName: string) {} + + get ownerDocument(): typeof fakeDocument { + return fakeDocument; + } + get parentElement(): FakeElement | null { + return this.parentNode; + } + get children(): FakeElement[] { + return this.childNodes.filter((child): child is FakeElement => typeof child !== "string"); + } + get firstChild(): Child | null { + return this.childNodes[0] ?? null; + } + get lastChild(): Child | null { + return this.childNodes[this.childNodes.length - 1] ?? null; + } + get textContent(): string { + return this.childNodes + .map((child) => (typeof child === "string" ? child : child.textContent)) + .join(""); + } + set textContent(value: string | null) { + this.replaceChildren(...(value ? [String(value)] : [])); + } + + private classes(): string[] { + return (this.attributes.get("class") ?? "").split(/\s+/).filter(Boolean); + } + private setClasses(names: string[]): void { + this.attributes.set("class", [...new Set(names)].join(" ")); + } + private adopt(child: Child): Child { + if (typeof child !== "string") { + child.parentNode?.removeChild(child); + child.parentNode = this; + } + return child; + } + + setAttribute(name: string, value: unknown): void { + this.attributes.set(name, String(value)); + } + setAttributeNS(_ns: string | null, name: string, value: unknown): void { + this.setAttribute(name, value); + } + getAttribute(name: string): string | null { + return this.attributes.get(name) ?? null; + } + hasAttribute(name: string): boolean { + return this.attributes.has(name); + } + removeAttribute(name: string): void { + this.attributes.delete(name); + } + append(...nodes: Child[]): void { + for (const node of nodes) this.childNodes.push(this.adopt(node)); + } + appendChild(node: T): T { + this.append(node); + return node; + } + prepend(...nodes: Child[]): void { + this.childNodes.unshift(...nodes.map((node) => this.adopt(node))); + } + insertBefore(node: T, reference: Child | null): T { + const index = reference ? this.childNodes.indexOf(reference) : -1; + if (index < 0) return this.appendChild(node); + this.childNodes.splice(index, 0, this.adopt(node)); + return node; + } + removeChild(node: T): T { + const index = this.childNodes.indexOf(node); + if (index >= 0) this.childNodes.splice(index, 1); + if (typeof node !== "string") node.parentNode = null; + return node; + } + replaceChildren(...nodes: Child[]): void { + for (const child of [...this.childNodes]) this.removeChild(child); + this.append(...nodes); + } + remove(): void { + this.parentNode?.removeChild(this); + } + closest(selectors: string): FakeElement | null { + for (let node: FakeElement | null = this; node; node = node.parentNode) { + if (matches(node, selectors)) return node; + } + return null; + } + querySelectorAll(selectors: string): FakeElement[] { + const found: FakeElement[] = []; + const walk = (node: FakeElement): void => { + for (const child of node.children) { + if (matches(child, selectors)) found.push(child); + walk(child); + } + }; + walk(this); + return found; + } + querySelector(selectors: string): FakeElement | null { + return this.querySelectorAll(selectors)[0] ?? null; + } + addEventListener(): void {} + removeEventListener(): void {} +} + +const fakeDocument = { + createElementNS: (_ns: string, tag: string) => new FakeElement(tag), + createElement: (tag: string) => new FakeElement(tag), + createTextNode: (text: string) => text, + // 오프스크린 SVG 는 문서에 안 붙어 있다 — 브라우저도 여기서 null 을 낸다(클립 해칭은 안 자름). + getElementById: () => null, + // 화면 모듈이 불러올 때 거는 문서 이벤트(누름 등) — 서버에는 사람이 없어 빈 함수 + addEventListener: () => undefined, + removeEventListener: () => undefined, +}; + +(globalThis as Record).document = fakeDocument; + +export {}; // 모듈로 둠 — 이름이 전역에 새지 않게 diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts b/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts index c4e446c65..20180f015 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts @@ -14,40 +14,9 @@ import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; import { loadSectionDetail } from "../B06_Section/B06_Section_Section_Store"; -import { - DESIGN_LAYER_ID, - entityBox, - overlayEntities, - type Entity, -} from "./B07_DesignDetail_Cross_Overlay"; +import { placeStructureEntities, type CrossPlacement } from "./B07_DesignDetail_Cross_Overlay"; -/** 서버가 도면에 실어 보내는 측점별 실좌표(m) → 종이(mm) 변환값. */ -export interface CrossPlacement { - chainage_m: number; - ox: number; - oy: number; - dy: number; - mm_per_m: number; - x0: number; - x1: number; - /** 블록 테두리(종이 mm) [x0, y0, x1, y1] — 그림을 이 안으로 자른다. */ - frame?: number[]; - /** 측점 한 벌 묶음(180-1) — 덧그림도 같은 묶음으로 함께 골라짐. */ - group_id?: string; - /** 측점 도면층(212-2) — 덧그림도 그 층에. */ - layer_id?: string; - /** 갈아 끼울 서버 설계선 id(212-2) — 측점 층이면 층으로는 못 가림. */ - design_ids?: string[]; -} - -/** 개체(자식 선까지)를 측점 층으로(212-2). */ -function moveToLayer(entity: Entity, layerId: string): void { - entity.layerId = layerId; - for (const child of (entity.children as Entity[] | undefined) ?? []) moveToLayer(child, layerId); -} - -/** 측점과 도면 배치를 같은 자리로 볼 허용 오차(m). 정본이 누가거리를 0.01m로 끊어 쓴다. */ -const CHAINAGE_TOLERANCE_M = 0.02; +export type { CrossPlacement }; /** * 도면에 구조물 엔티티를 얹는다 (제자리 수정). 배치 메타가 없거나 자료를 못 읽으면 @@ -68,48 +37,6 @@ export async function appendStructureEntities( } catch { return 0; } - - let added = 0; - const fresh: Entity[] = []; - const replaced: number[][] = []; - const designIds = new Set(); - for (const placement of placements) { - const section = sections.find( - (item) => Math.abs(item.chainage_m - placement.chainage_m) <= CHAINAGE_TOLERANCE_M, - ); - const frame = placement.frame; - if (!section || !frame) continue; - const x = (offset: number): number => offset * placement.mm_per_m + placement.ox; - const y = (elevation: number): number => - -((elevation - placement.dy) * placement.mm_per_m + placement.oy); - let entities: Entity[]; - try { - const drawn = overlayEntities(section, sections, x, y, frame); - if (!drawn) continue; - entities = drawn; - } catch { - continue; // 한 측점의 기하 실패가 도면 전체를 막으면 안 된다. - } - if (!entities.length) continue; - if (placement.group_id) for (const entity of entities) entity.groupId = placement.group_id; - if (placement.layer_id) for (const entity of entities) moveToLayer(entity, placement.layer_id); - for (const id of placement.design_ids ?? []) designIds.add(id); - // 이 블록 설계선은 우리가 다시 그렸다 — 서버가 낸 트림 전 설계선은 걷어낸다. - replaced.push(frame); - fresh.push(...entities); - added += entities.length; - } - if (replaced.length) { - // 걷어내기는 새 엔티티를 넣기 **전에** 한다 — 뒤에 하면 우리 것까지 같이 지운다. - drawing.entities = drawing.entities.filter((entity) => { - if (entity.layerId !== DESIGN_LAYER_ID && !designIds.has(entity.id as string)) return true; - const box = entityBox(entity); - if (!box) return true; - return !replaced.some( - ([fx0, fy0, fx1, fy1]) => box[0] >= fx0 && box[2] <= fx1 && box[1] >= fy0 && box[3] <= fy1, - ); - }); - } - drawing.entities.push(...fresh); - return added; + // 얹는 몸통 = `_Cross_Overlay.placeStructureEntities`(인쇄 Node 302-2 와 한 벌) + return placeStructureEntities(sections, drawing); } diff --git a/B08_Quantity/B08_Quantity_Earthwork_Tables.ts b/B08_Quantity/B08_Quantity_Earthwork_Tables.ts new file mode 100644 index 000000000..efa789d0e --- /dev/null +++ b/B08_Quantity/B08_Quantity_Earthwork_Tables.ts @@ -0,0 +1,327 @@ +/* ============================================================================= + * B08_Quantity_Earthwork_Tables.ts + * 토공 보조 탭 넷(PLAN 61-3 · 69-2) 자료 읽기 · 표 짜기 — 화면 없음(PLAN 302-3). + * 화면(`…_UI_Earthwork` · `…_UI_Earthwork_Sheets`) · 인쇄 미리보기(`B09_Draft_Print_Quantity`)가 같은 함수를 씀. + * + * `loadEarthwork` = GET …/quantity/earthwork + 측점 간격 · `applyRock` = 연암 · 경암 = 암 × 좌측 패널 비율 · + * 암 C = 비율 가중(캐시 → 저장본 · `…_Options_Calc` · PLAN 122 · 125-1) + * 토적표 = 견본 차례 32칸 · 합계 줄 · 첫 측점은 앞이 없어 입적 · 면적이 빈 값 + * 토공집계표 · 평균운반거리 · 종방향 무대운반거리 = 거창 실무 워크북 같은 이름 시트 칸 + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import type { DataColumn, DataRow, DataTableOptions } from "@ui/ui_template_data_table"; +import { stationLabel } from "../common_util/common_util_station"; +import { + mixedRockC, + recountAdjustedSummary, + recountRockC, + splitRockColumns, + splitRockSummary, +} from "./B08_Quantity_Options_Calc"; +import { stationInterval } from "./B08_Quantity_Summary_Table"; +import { liveSoftRock } from "./B08_Quantity_UI_Options"; + +type Face = { length_m: number | null; area_m2: number | null }; + +interface EarthworkRow { + chainage_m: number; + distance_m: number; + cut_soil_area_m2: number; + cut_soil_volume_m3: number; + cut_soil_adjusted_m3: number; + cut_rock_area_m2: number; + cut_rock_volume_m3: number; + cut_rock_adjusted_m3: number; + cut_rock_kind: string; + ditch_soil_area_m2: number; + ditch_soil_volume_m3: number; + ditch_soil_adjusted_m3: number; + ditch_rock_area_m2: number; + ditch_rock_volume_m3: number; + ditch_rock_adjusted_m3: number; + adjusted_total_m3: number; + fill_area_m2: number; + fill_volume_m3: number; + diverted_m3: number; + balance_m3: number; + cumulative_m3: number; + notes: string[]; + faces: Record; +} + +export interface HaulRow { + q: number; + l: number; + ql: number; +} + +export interface HaulList { + rows: HaulRow[]; + q: number; + ql: number; + average_m: number | null; +} + +export interface SummaryRow { + group: string; + kind: string; + spec: string; + unit: string; + value: number | null; + note: string; +} + +export interface EarthworkTable { + rows: EarthworkRow[]; + totals: Record; + conversion_factors: Record; + station_count: number; + route_id: number; + confirmed: boolean; + summary: SummaryRow[]; + haul: Record<"dump_truck" | "dozer" | "free_haul", HaulList>; + haul_saved: boolean; + /** 저장된 연암 · 경암 %(암 전체 기준) */ + rock_ratios_pct: { 연암: number; 경암: number }; + /** 가중 전 리핑암(연암) · 발파암(경암) C — 비율로 다시 가중(125-1) */ + rock_c: { ripping_rock: number; blasting_rock: number }; +} + +/** 응답 + 측점 간격 · applyRock = 지금 좌측 패널 비율로 표를 고쳐 씀(조작마다 다시) */ +export async function loadEarthwork( + projectId: string, +): Promise<{ table: EarthworkTable; interval: number; applyRock: () => void }> { + const [response, interval] = await Promise.all([ + fetch(`${API_BASE_URL}/projects/${projectId}/quantity/earthwork`, { credentials: "include" }), + stationInterval(projectId).catch(() => 20), + ]); + const body = (await response.json().catch(() => null)) as + (EarthworkTable & { detail?: unknown }) | null; + if (!response.ok || !body) { + const detail = body?.detail; + throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`); + } + const table: EarthworkTable = body; + // 연암 · 경암 칸 · 집계표 줄 = 암 × 지금 비율 · 암 C = 비율 가중(캐시 → 저장본 · 125-1) + const summary = table.summary; + const applyRock = (): void => { + const soft = liveSoftRock(projectId, table.rock_ratios_pct?.연암 ?? 90); + const c = mixedRockC(soft, table.rock_c.ripping_rock, table.rock_c.blasting_rock); + for (const kind of ["ripping_rock", "blasting_rock"]) + table.conversion_factors[kind].compacted = c; + recountRockC(table.rows as unknown as Array>, table.totals, c); + for (const record of [...table.rows, table.totals]) + splitRockColumns(record as unknown as Record, soft); + table.summary = recountAdjustedSummary( + splitRockSummary(summary, table.totals, soft), + table.totals, + table.haul.free_haul.q, + ); + }; + applyRock(); + return { table, interval, applyRock }; +} + +/** 보조 탭 넷 — 화면 탭 줄 · 인쇄 쪽 차례 */ +export const EARTHWORK_SHEETS = [ + { id: "summary", label: "토공집계표" }, + { id: "sheet", label: "토적표" }, + { id: "haul", label: "평균운반거리" }, + { id: "free", label: "종방향 무대운반거리" }, +] as const; + +/** 칸 묶음 — [머리 묶음, 키 앞머리, 풍선 글] · 단면적 · 입적 · 보정량 셋 · 머리 글자는 사용자 견본대로 */ +const TRIPLES: Array<[string[], string, string?]> = [ + [["절토", "토사"], "cut_soil"], + [["절토", "연암"], "cut_soft_rock", "암 × 연암 %"], + [["절토", "경암"], "cut_hard_rock", "암 × 경암 %"], + [["측구파기", "토사"], "ditch_soil"], + [["측구파기", "암"], "ditch_rock", "리핑암 · 발파암 합"], +]; +/** 면 계열 — [머리 묶음, 키] · 거리 · 면적 둘 */ +const FACES: Array<[string[], string]> = [ + [["면고르기", "성토면"], "grading_fill"], + [["면고르기", "절토면"], "grading_cut"], + [["지장목제거", "성토"], "tree_removal_fill"], + [["지장목제거", "절토"], "tree_removal_cut"], + [["제근"], "grubbing"], + [["노면정리"], "road_surface"], +]; + +const n2 = (key: string, label: string, group?: string[]): DataColumn => ({ + key, + label, + group, + kind: "number", + decimals: 2, +}); + +/** 견본 차례 32칸 */ +const COLUMNS: DataColumn[] = [ + { key: "station", label: "측점", width: 72 }, + n2("distance", "거리"), + ...TRIPLES.flatMap(([group, key, tip]) => + [ + n2(`${key}_area`, "단면적", group), + n2(`${key}_volume`, "입적", group), + n2(`${key}_adjusted`, "보정량", group), + ].map((c) => (tip ? { ...c, title: `${group.at(-1)} ${c.label} — ${tip}` } : c)), + ), + n2("adjusted_total", "보정량계"), + n2("fill_area", "단면적", ["성토"]), + n2("fill_volume", "입적", ["성토"]), + n2("diverted", "유용토"), + n2("balance", "차인토량"), + n2("cumulative", "누가토량"), + ...FACES.flatMap(([group, key]) => [ + n2(`${key}_length`, "거리", group), + n2(`${key}_area`, "면적", group), + ]), +]; + +function stationRow(row: EarthworkRow, first: boolean, interval: number): DataRow { + // 첫 측점은 앞이 없어 입적 · 보정량 · 면적이 없음(빈 값) + const v = (value: number): number | null => (first ? null : value); + const out: DataRow = { + station: stationLabel(row.chainage_m, interval) + (row.notes.length ? " *" : ""), + distance: row.distance_m, + adjusted_total: v(row.adjusted_total_m3), + fill_area: row.fill_area_m2, + fill_volume: v(row.fill_volume_m3), + diverted: v(row.diverted_m3), + balance: v(row.balance_m3), + cumulative: v(row.cumulative_m3), + }; + const r = row as unknown as Record; + for (const [, key] of TRIPLES) { + out[`${key}_area`] = r[`${key}_area_m2`]; + out[`${key}_volume`] = v(r[`${key}_volume_m3`]); + out[`${key}_adjusted`] = v(r[`${key}_adjusted_m3`]); + } + for (const [, key] of FACES) { + out[`${key}_length`] = row.faces?.[key]?.length_m ?? null; + out[`${key}_area`] = first ? null : (row.faces?.[key]?.area_m2 ?? null); + } + return out; +} + +/** 합계 줄 — 단면적 · 거리는 합이 뜻이 없어 비움(옛 엔진 `totals` 와 같음) · 누가토량 = 마지막 줄 */ +function totalRow(table: EarthworkTable): DataRow { + const t = table.totals; + const out: DataRow = { + station: "합계", + distance: t.distance_m, + adjusted_total: t.adjusted_total_m3, + fill_volume: t.fill_volume_m3, + diverted: t.diverted_m3, + balance: t.balance_m3, + cumulative: table.rows[table.rows.length - 1]?.cumulative_m3, + }; + for (const [, key] of TRIPLES) { + out[`${key}_volume`] = t[`${key}_volume_m3`]; + out[`${key}_adjusted`] = t[`${key}_adjusted_m3`]; + } + // 값이 하나도 없는 칸(제근 — B07 에 값 없음)은 합계도 비움 + for (const [, key] of FACES) { + const any = table.rows.some((row) => row.faces?.[key]?.area_m2 != null); + out[`${key}_area`] = any ? t[`${key}_area_m2`] : null; + } + return out; +} + +/** 토적표 */ +export const earthworkSheetOptions = ( + table: EarthworkTable, + interval: number, +): DataTableOptions => ({ + columns: COLUMNS, + rows: table.rows.map((row, i) => stationRow(row, i === 0, interval)), + frozen: 1, + total: totalRow(table), + caption: "토적표", +}); + +const SUMMARY_COLUMNS: DataColumn[] = [ + { key: "group", label: "구분", width: 120 }, + { key: "kind", label: "공종", width: 72 }, + { key: "spec", label: "규격", width: 128 }, + { key: "unit", label: "단위", width: 56 }, + { key: "value", label: "계", kind: "number", decimals: 2, width: 112 }, + { key: "note", label: "비고", width: 200 }, +]; + +/** 토공집계표 — 구분 · 공종 · 규격 · 단위 · 계 · 비고 */ +export const earthworkSummaryOptions = (table: EarthworkTable): DataTableOptions => ({ + columns: SUMMARY_COLUMNS, + // 칸이 없는 글(공종 · 규격 · 비고)은 빈칸 — 공용 표는 "" 를 「-」(값 없음)로 그림 + rows: table.summary.map((row) => ({ + ...row, + kind: row.kind || " ", + spec: row.spec || " ", + note: row.note || " ", + })), + frozen: 1, + mergeSame: ["group", "spec"], // 201-1 같은 구분 · 규격 이어지면 세로 병합(빈칸은 안 합침) + caption: "토공집계표", +}); + +const fmt = (value: number | null | undefined): string => + value == null || !Number.isFinite(value) + ? "-" + : value.toLocaleString("ko-KR", { maximumFractionDigits: 2 }); + +/** 운반 시트 둘 — [묶음 이름, 목록] · 표 이름 */ +export const HAUL_SHEETS = { + haul: (table: EarthworkTable) => ({ + groups: [ + ["덤프트럭", table.haul.dump_truck], + ["불도우저", table.haul.dozer], + ] as Array<[string, HaulList]>, + caption: "덤프트럭 · 불도우저 평균운반거리 — 단위 ㎥ · m", + }), + free: (table: EarthworkTable) => ({ + groups: [["종방향무대", table.haul.free_haul]] as Array<[string, HaulList]>, + caption: "종방향 무대운반거리 — 단위 ㎥ · m", + }), +}; + +/** 운반 시트 한 장 — 묶음마다 토량(Q) · 거리(L) · Q×L · 맨 아래 합계 · averages = 아래 평균운반거리 줄 */ +export function haulOptions( + groups: Array<[string, HaulList]>, + caption: string, +): { options: DataTableOptions; averages: string[] } { + const columns: DataColumn[] = [ + { key: "no", label: "번호", width: 56 }, + ...groups.flatMap(([label], g) => + [ + ["q", "토량(Q)"], + ["l", "거리(L)"], + ["ql", "Q×L"], + ].map(([key, text]) => ({ + key: `${key}${g}`, + label: text, + group: [label], + kind: "number" as const, + decimals: 2, + width: 104, + })), + ), + ]; + const count = Math.max(0, ...groups.map(([, list]) => list.rows.length)); + const rows: DataRow[] = Array.from({ length: count }, (_, i) => { + const out: DataRow = { no: String(i + 1) }; + groups.forEach(([, list], g) => { + const item = list.rows[i]; + if (item) Object.assign(out, { [`q${g}`]: item.q, [`l${g}`]: item.l, [`ql${g}`]: item.ql }); + }); + return out; + }); + const total: DataRow = { no: "합계" }; + groups.forEach(([, list], g) => Object.assign(total, { [`q${g}`]: list.q, [`ql${g}`]: list.ql })); + const averages = groups.map( + ([label, list], i) => + `${i + 1}) ${label} 평균운반거리: ${fmt(list.ql)} ÷ ${fmt(list.q)} = ${fmt(list.average_m)} M`, + ); + return { options: { columns, rows, frozen: 1, total, caption }, averages }; +} diff --git a/B08_Quantity/B08_Quantity_Hydro_Table.ts b/B08_Quantity/B08_Quantity_Hydro_Table.ts new file mode 100644 index 000000000..6444aa257 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Hydro_Table.ts @@ -0,0 +1,188 @@ +/* ============================================================================= + * B08_Quantity_Hydro_Table.ts + * 수리집수면적표(PLAN 62-2 · 69-4) 자료 읽기 · 측점 입력 겹치기 · 인쇄 표 — 화면 없음(PLAN 302-3). + * 화면(`B08_Quantity_UI_Hydro`) · 인쇄 미리보기(`B09_Draft_Print_Quantity`)가 같은 입력 · 같은 서버 계산을 씀. + * + * 값 차례 = 기본 < 장 기본 < 연결 < 저장 < 초안(캐시 `b08-hydro`) · 연결값과 같은 칸은 null 로 저장(연동 유지) + * 계산 = 서버 단독(POST …/quantity/hydro/calc) + * `hydroTable` = 계산서 한 장(화면 두 카드 칸 차례 그대로)을 구분 · 항목 · 값 표로 — 인쇄 전용 + * ========================================================================== */ + +import type { DataTableOptions } from "@ui/ui_template_data_table"; +import { readState } from "../A00_Common/b_page_state"; +import { request } from "./B08_Quantity_Material_Tables"; +import { stationInterval } from "./B08_Quantity_Summary_Table"; +import type { + HydroInputs, + HydroLink, + HydroOptions, + HydroResult, +} from "./B08_Quantity_UI_Hydro_Sheet"; + +export interface HydroStation { + chainage_m: number; + facility: string; + /** 박스암거 · 세월교 · 물넘이포장(관이면 "") */ + kind: string; + /** 계산서 장 — 시설이 정함(서버 station_rows) */ + sheet: HydroInputs["sheet"]; + /** B05 연결값(값이 있는 칸만) · 비교용 B05 Qd · T — 서버 station_rows */ + linked: HydroLink["values"] | null; + b05: HydroLink["b05"]; +} + +export type Drafts = Record>; +export type HydroPayload = { stations: HydroStation[]; saved: Drafts } & HydroOptions; + +export const keyOf = (station: HydroStation): string => station.chainage_m.toFixed(3); + +const linkedOf = (station: HydroStation): Record => station.linked ?? {}; + +/** 저장본 · 초안 한 겹 — 연결값이 있는 칸의 null 은 건너뜀(연결값을 덮지 않게) */ +const layer = (station: HydroStation, values: Partial = {}) => + Object.fromEntries( + Object.entries(values).filter(([k, v]) => v != null || !(k in linkedOf(station))), + ); + +/** 측점 입력 — 기본 < 장 기본 < 연결 < 저장 < 초안 */ +export const hydroInputs = ( + sheet: HydroPayload, + station: HydroStation, + drafts: Drafts, +): HydroInputs => + ({ + ...sheet.defaults, + ...(station.sheet === "pipe" ? {} : sheet.sheet_defaults[station.sheet]), + ...station.linked, + // 초안은 측점 입력 통째 — 있으면 저장본 대신(초안의 null = 연결값으로 되돌림) + ...layer(station, drafts[keyOf(station)] ?? sheet.saved[keyOf(station)]), + sheet: station.sheet, + }) as HydroInputs; + +/** 연결값과 같은 칸은 null — 저장 · 초안이 연동을 유지(B05 값이 바뀌면 따라옴) · 다르면 손 값 */ +export const unlinkedInputs = (station: HydroStation, inputs: HydroInputs): HydroInputs => + Object.fromEntries( + Object.entries(inputs).map(([k, v]) => [k, linkedOf(station)[k] === v ? null : v]), + ) as unknown as HydroInputs; + +export async function loadHydro( + projectId: string, +): Promise<{ sheet: HydroPayload; interval: number; drafts: Drafts }> { + const [sheet, interval] = await Promise.all([ + request(`/projects/${projectId}/quantity/hydro`), + stationInterval(projectId).catch(() => 20), + ]); + return { sheet, interval, drafts: readState("b08-hydro", projectId) ?? {} }; +} + +export const hydroCalc = (projectId: string, inputs: HydroInputs): Promise => + request(`/projects/${projectId}/quantity/hydro/calc`, { + method: "POST", + body: JSON.stringify(inputs), + }); + +const DASH = "-"; +const fmt = (value: number | null | undefined, digits: number, unit = ""): string => + value == null || !Number.isFinite(value) ? DASH : `${value.toFixed(digits)}${unit}`; +const raw = (value: number | string | null | undefined, unit = ""): string => + value == null || value === "" ? DASH : `${value}${unit}`; + +/** 계산서 한 장 → 표(구분 · 항목 · 값) — 칸 이름 · 자리수 = 화면 계산서(`…_UI_Hydro_Sheet`) */ +export function hydroTable( + stationText: string, + kind: string, + inputs: HydroInputs, + r: HydroResult | null, + options: HydroOptions, +): DataTableOptions { + const site = options.site; + const flood = "1. 홍수량 계산"; + const sheetDefaults = inputs.sheet === "pipe" ? {} : options.sheet_defaults[inputs.sheet]; + const n = + inputs.n ?? (inputs.sheet === "pipe" ? options.pipe_kinds[inputs.pipe_kind] : sheetDefaults.n); + const slope = [ + "하상경사 S = 1 /", + `${raw(inputs.slope_denominator)} (${fmt(r?.slope_bed == null ? null : r.slope_bed * 100, 2, " %")})`, + ]; + const section: Array<[string, string]> = + inputs.sheet === "box" + ? [ + ["내공 폭 B (m)", raw(inputs.box_width_m)], + ["내공 높이 H (m)", raw(inputs.box_height_m)], + ["수심비 h / H", raw(inputs.depth_ratio)], + ["수심 h (m)", fmt(r?.depth, 2)], + ["단면적", fmt(r?.section_area, 3, " ㎡")], + ["윤변", fmt(r?.perimeter, 3, " m")], + ["경심", fmt(r?.radius, 3)], + slope as [string, string], + ["조도계수 n", raw(n)], + ] + : inputs.sheet === "ford" + ? [ + ["월류 폭 B (m)", raw(inputs.overflow_width_m)], + slope as [string, string], + ["조도계수 n", raw(n)], + ["월류 높이 h (m)", fmt(r?.depth, 2)], + ["단면적", fmt(r?.section_area, 3, " ㎡")], + ["윤변", fmt(r?.perimeter, 3, " m")], + ["경심", fmt(r?.radius, 3)], + ] + : [ + ["관 종류", inputs.pipe_kind], + ["단면 가정", options.sections[inputs.section] ?? inputs.section], + ["단면 D (mm)", raw(inputs.diameter_mm)], + ["단면적", fmt(r?.section_area, 3, " ㎡")], + ["윤변", fmt(r?.perimeter, 3, " m")], + ["경심", fmt(r?.radius, 3)], + slope as [string, string], + ["조도계수 n", raw(n)], + ]; + const judged = r != null && r.ok != null; + const check = + inputs.sheet === "box" + ? `2. 단면 검토 — ${kind}` + : inputs.sheet === "ford" + ? `2. 월류 높이 — ${kind}` + : "2. 단면 검토"; + const rows: Array<[string, string, string]> = [ + [flood, `Qd : 설계 홍수량 (㎥/s) = Q × ${inputs.factor.toFixed(1)}`, fmt(r?.design_flow, 2)], + [flood, "Q : 홍수량 (㎥/s)", fmt(r?.flow, 2)], + [flood, "f : 유출계수", raw(inputs.runoff)], + [flood, "rt : 평균 강우강도 (mm/hr)", fmt(r?.intensity, 2)], + [flood, "A : 유역면적 (㎢)", `${raw(inputs.area_km2)} (${fmt(r?.area_ha, 1, " ha")})`], + [flood, "T : 홍수 도달시간 (hr)", fmt(r?.tc_hr, 3)], + [flood, "L : 산정에서 하천시점까지 거리 (m)", raw(inputs.length_m)], + [flood, "H : 산정에서 하천시점까지 표고차 (m)", raw(inputs.height_m)], + [flood, "s : 지표면 경사 (H / L)", fmt(r?.slope_ground, 3)], + [flood, "최소 도달시간 (분)", raw(inputs.tc_min_minutes)], + [flood, "지역", site.region.name || raw(inputs.region)], + [flood, "재현기간", `${inputs.return_period} 년`], + [ + flood, + "R24 : 확률강우량 (mm/day)", + fmt(r?.r24_mm ?? site.r24[String(inputs.return_period)], 1), + ], + [check, "총 홍수량 Qd (㎥/s)", fmt(r?.design_flow, 2)], + ...section.map(([label, value]) => [check, label, value] as [string, string, string]), + [check, "유속 (m/s)", fmt(r?.velocity, 3)], + [check, "유출량 (㎥/s)", fmt(r?.capacity, 3)], + [ + check, + "판정", + judged + ? `${fmt(r.capacity, 3)} ㎥/s ${r.ok ? "≥" : "<"} Qd ${fmt(r.design_flow, 2)} ㎥/s — ${r.ok ? "O K" : "N G"}` + : DASH, + ], + [check, "유출율 (%)", fmt(r?.ratio_pct, 0)], + ]; + return { + columns: [ + { key: "group", label: "구분", width: 120 }, + { key: "item", label: "항목", width: 260 }, + { key: "value", label: "값", width: 240 }, + ], + rows: rows.map(([group, item, value]) => ({ group, item, value })), + mergeSame: ["group"], + caption: `홍수량 계산서 ${stationText}${kind ? ` ${kind}` : ""}`, + }; +} diff --git a/B08_Quantity/B08_Quantity_Material_Tables.ts b/B08_Quantity/B08_Quantity_Material_Tables.ts new file mode 100644 index 000000000..c81c546e4 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Material_Tables.ts @@ -0,0 +1,261 @@ +/* ============================================================================= + * B08_Quantity_Material_Tables.ts + * 자재 보조 탭 셋(PLAN 121-1) 자료 읽기 · 표 짜기 — 화면 없음(PLAN 302-3). + * 화면(`B08_Quantity_UI_Material`) · 인쇄 미리보기(`B09_Draft_Print_Quantity`)가 같은 함수를 씀. + * + * `loadMaterials` = GET …/quantity/materials + 캐시(`b08-materials`) + 좌측 패널 할증 → 짝 계산 + * `materialTable` = 보조 탭 한 장의 열 · 줄 · 계 · 고칠 칸 · 고친 칸 자리 + * `materialOptions` = 그 장의 공용 표 옵션(화면은 고치기 · 높이만 더함) + * 값 없는 줄은 숨김(143-3) · 콘크리트소요는 5줄 늘 보임(163-5) + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import type { DataColumn, DataRow, DataTableOptions } from "@ui/ui_template_data_table"; +import { readState } from "../A00_Common/b_page_state"; +import { + computeMaterialSheets, + DEDUCT_KEY, + MIX_FIELDS, + type Cells, + type MaterialOverrides, + type MaterialSheets, + type SummarySource, +} from "./B08_Quantity_Material_Calc"; +import { liveSurcharge } from "./B08_Quantity_UI_Options"; + +export type MaterialSheetId = "material" | "concrete" | "quantity"; + +export interface Payload { + source: SummarySource; + overrides: MaterialOverrides; + /** 좌측 패널 할증 저장본(기본 채움) */ + surcharge_pct: Cells; + /** 구조물 집계표 알림(「집수정 모양 기록 없음 n곳」 등) — 총괄 · 콘크리트 위 빈 까닭(131) */ + notes?: string[]; + sheets: MaterialSheets; +} + +const BASIS = + "구조물 수량 기준 — 일위대가 재료와 별개(두 번 세지 않게 일위대가 재료배합과 겹치지 않는지 확인)"; + +/** 보일 줄 차례(원래 줄 번호) — 값 없는 줄은 숨김(143-3) */ +const shown = (rows: readonly T[], has: (row: T) => boolean): number[] => + rows.flatMap((row, i) => (has(row) ? [i] : [])); +const anyValue = (cells: Cells): boolean => Object.values(cells).some((v) => v != null); + +export async function request(path: string, init: RequestInit = {}): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { + credentials: "include", + headers: { "Content-Type": "application/json" }, + ...init, + }); + const body = (await response.json().catch(() => null)) as { detail?: unknown } | null; + if (!response.ok) { + const detail = body?.detail; + throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`); + } + return body as T; +} + +/** 저장본 + 캐시(조작 중 값) — calc = 지금 좌측 패널 할증으로 세 표를 다시 셈 */ +export async function loadMaterials(projectId: string): Promise<{ + payload: Payload; + overrides: MaterialOverrides; + calc: (payload: Payload, overrides: MaterialOverrides) => MaterialSheets; +}> { + const payload = await request(`/projects/${projectId}/quantity/materials`); + const draft = readState("b08-materials", projectId); + return { + payload, + overrides: draft ?? payload.overrides, + calc: (payload, overrides) => + computeMaterialSheets( + payload.source, + overrides, + liveSurcharge(projectId, payload.surcharge_pct), + ), + }; +} + +/** 숫자 열 — 최대 3 자리 · 끝 0 지움(고친 값 자리가 줄마다 다름) */ +const numberColumn = (column: DataColumn): DataColumn => ({ + kind: "number", + decimals: 3, + trim: true, + ...column, +}); + +export interface Table { + columns: DataColumn[]; + rows: DataRow[]; + total: DataRow | null; + frozen: number; + editable: (index: number, key: string) => boolean; + /** 고친 칸 → 사용자 값 자리 [표, 줄 키, 칸 키] */ + target: (index: number, key: string) => [Record | Cells, string, string | null]; + /** 같은 값 세로 병합 열(201-1 · 공용 표 mergeSame) */ + mergeSame?: string[]; +} + +function quantityTable(s: MaterialSheets, o: MaterialOverrides): Table { + const { materials, rows, total } = s.quantity; + const keep = shown(rows, (row) => row.qty != null || anyValue(row.units)); + const rowsOut: DataRow[] = keep.flatMap((at) => { + const row = rows[at]; + const unit: DataRow = { + name: row.name, + spec: row.spec, + qty: row.qty, + unit: row.unit, + kind: "단위수량", + }; + const amount: DataRow = { kind: "수량" }; + for (const m of materials) { + unit[`m:${m.key}`] = row.units[m.key]; + amount[`m:${m.key}`] = row.amounts[m.key]; + } + return [unit, amount]; + }); + const totalRow: DataRow = { name: "계", kind: "수량" }; + for (const m of materials) totalRow[`m:${m.key}`] = total[m.key]; + return { + columns: [ + { key: "name", label: "구분", width: 120 }, + { key: "spec", label: "규격", width: 120 }, + numberColumn({ key: "qty", label: "수량", width: 72 }), + { key: "unit", label: "단위", width: 56 }, + { key: "kind", label: "구분", width: 72 }, + ...materials.map((m) => + numberColumn({ + key: `m:${m.key}`, + label: m.unit, + group: m.spec ? [m.name, m.spec] : [m.name], + title: [m.name, m.spec, m.unit].filter(Boolean).join(" › "), + width: 76, + }), + ), + ], + rows: rowsOut, + total: totalRow, + frozen: 5, + editable: (i, key) => i % 2 === 0 && key.startsWith("m:"), + target: (i, key) => [o.units, rows[keep[i / 2]].id, key.slice(2)], + }; +} + +export const MIX_LABEL: Record<(typeof MIX_FIELDS)[number], [string, string, string]> = { + cement: ["시멘트", "㎥당(kg)", "수량(kg)"], + sand: ["모래", "㎥당(㎥)", "수량(㎥)"], + gravel: ["자갈", "㎥당(㎥)", "수량(㎥)"], +}; + +function concreteTable(s: MaterialSheets, o: MaterialOverrides): Table { + const { rows, total } = s.concrete; + const keep = rows.map((_row, i) => i); // 5줄 늘 보임(163-5) — 수량 없으면 빈칸 · 배합은 늘 고침 + const rowsOut: DataRow[] = keep.map((at) => { + const row = rows[at]; + const out: DataRow = { + name: row.name, // 위 줄과 같으면 세로 병합(201-1) + spec: row.spec, + qty: row.qty, + }; + for (const f of MIX_FIELDS) { + out[`per:${f}`] = row.per[f]; + out[`amt:${f}`] = row.amounts[f]; + } + return out; + }); + const totalRow: DataRow = { name: "계" }; + for (const f of MIX_FIELDS) totalRow[`amt:${f}`] = total[f]; + return { + columns: [ + { key: "name", label: "구분", width: 88 }, + { key: "spec", label: "규격", width: 110 }, + numberColumn({ key: "qty", label: "수량(㎥)", width: 80 }), + ...MIX_FIELDS.flatMap((f) => [ + numberColumn({ + key: `per:${f}`, + label: MIX_LABEL[f][1], + group: [MIX_LABEL[f][0]], + width: 84, + }), + numberColumn({ + key: `amt:${f}`, + label: MIX_LABEL[f][2], + group: [MIX_LABEL[f][0]], + width: 96, + }), + ]), + ], + rows: rowsOut, + total: totalRow, + frozen: 2, + mergeSame: ["name"], + editable: (_i, key) => key.startsWith("per:"), + target: (i, key) => [o.mix, rows[keep[i]].key, key.slice(4)], + }; +} + +function totalTable(s: MaterialSheets, o: MaterialOverrides): Table { + const { rows } = s.material; + const lost = new Set(s.quantity.unmatched); + const keep = shown(rows, (row) => row.qty != null || row.amount != null); + return { + columns: [ + { key: "group", label: "구분", width: 96 }, + { key: "spec", label: "규격", width: 120 }, + { key: "unit", label: "단위", width: 56 }, + numberColumn({ key: "qty", label: "수량", width: 96 }), + numberColumn({ key: "surcharge", label: "할증(%)", width: 72 }), + numberColumn({ key: "amount", label: "합계", width: 96 }), + { key: "note", label: "비고", width: 96 }, + ], + rows: keep.map((at) => { + const row = rows[at]; + return { + group: row.group, // 위 줄과 같으면 세로 병합(201-1) + spec: row.spec, + unit: row.unit, + qty: row.qty, + surcharge: row.surcharge, + amount: row.amount, + note: row.panel + ? "할증 = 좌측 패널" + : row.key === DEDUCT_KEY + ? "토공 잔토처리에서 뺌" + : lost.has(row.key) + ? "2공구 맞춤 없음" + : "", + }; + }), + total: null, + frozen: 2, + mergeSame: ["group"], + editable: (i, key) => + key === "surcharge" && !rows[keep[i]].panel && rows[keep[i]].key !== DEDUCT_KEY, + target: (i) => [o.surcharge, rows[keep[i]].key, null], + }; +} + +const BUILD: Record Table> = { + material: totalTable, + concrete: concreteTable, + quantity: quantityTable, +}; + +export const materialTable = ( + sheet: MaterialSheetId, + s: MaterialSheets, + o: MaterialOverrides, +): Table => BUILD[sheet](s, o); + +/** 공용 표 옵션 — 고치기 · 높이는 화면이 더함 */ +export const materialOptions = (built: Table): DataTableOptions => ({ + columns: built.columns, + rows: built.rows, + total: built.total, + frozen: built.frozen, + mergeSame: built.mergeSame, + empty: "", + caption: BASIS, +}); diff --git a/B08_Quantity/B08_Quantity_Milestone_Table.ts b/B08_Quantity/B08_Quantity_Milestone_Table.ts new file mode 100644 index 000000000..b35e69047 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Milestone_Table.ts @@ -0,0 +1,68 @@ +/* ============================================================================= + * B08_Quantity_Milestone_Table.ts + * 거리이정표 표(PLAN 244-1 · 244-13) 짜기 — 화면 없음(PLAN 302-3). + * 화면(`B08_Quantity_UI_Milestone`) · 인쇄 미리보기(`B09_Draft_Print_Quantity`)가 같은 함수를 씀. + * + * 열 = 기준점 있음 = 2차포장 · 1차포장 (· 포장) · 비포장 + 계 포장 · 비포장 · 합계 / 없음 = 포장 · 비포장 · 합계 + * 줄 = TS 짝(`…_Milestone_Calc.computeMilestone`) · 목적지 = 글(화면은 고름 표 ▾ 를 덧붙임) + * ========================================================================== */ + +import type { DataColumn, DataTableOptions } from "@ui/ui_template_data_table"; +import { + computeMilestone, + type Dest, + type MilestoneData, + type MilestoneTableRow, +} from "./B08_Quantity_Milestone_Calc"; + +export const DEST_TEXT: Record = { start: "시점", mid: "중점" }; + +const KM = { kind: "number", decimals: 3, trim: true, width: 72 } as const; +const ROAD = ["도로 구분 (km)"]; +const SUM = ["계 (km)"]; + +/** 표 열(244-13) — 2차포장 열 = 기준점 있을 때만 · 기준점 없음 = 포장(2차 + 1차 + 포장) · 비포장 · 합계 · + * 기준점 있음 = 거침 안 한 거래처 「포장」 값이 있으면 포장 열도 */ +export function milestoneColumns(withBase: boolean, anyPaved: boolean): DataColumn[] { + const roads: DataColumn[] = withBase + ? [ + { key: "s2", label: "2차포장", group: ROAD, ...KM }, + { key: "s1", label: "1차포장", group: ROAD, ...KM }, + ...(anyPaved ? [{ key: "pv", label: "포장", group: ROAD, ...KM }] : []), + { key: "un", label: "비포장", group: ROAD, ...KM }, + { key: "paved", label: "포장", group: SUM, ...KM }, + { key: "unpaved", label: "비포장", group: SUM, ...KM }, + ] + : [ + { key: "paved", label: "포장", group: ROAD, ...KM }, + { key: "un", label: "비포장", group: ROAD, ...KM }, + ]; + return [ + { key: "item", label: "항목", width: 134 }, + { key: "company", label: "거래처 이름", width: 120 }, + { key: "dest", label: "목적지", width: 72 }, + ...roads, + { key: "total", label: "합계", group: SUM, ...KM, width: 108 }, + { key: "note", label: "비고", width: 160 }, + ]; +} + +/** 표 한 벌 — options(열 · 줄 · 이름) · rows = 짝 계산 줄(경로 id · 줄 차례 · km) · mode = 열 꼴 표 */ +export function milestoneTable( + data: MilestoneData, + midDefaultM: number | null, +): { options: DataTableOptions; rows: MilestoneTableRow[]; mode: string } { + const rows = computeMilestone(data, midDefaultM); + const withBase = data.bases.length > 0; + const anyPaved = rows.some((r) => r.pv > 0); + return { + options: { + columns: milestoneColumns(withBase, anyPaved), + rows: rows.map((r) => ({ ...r, dest: DEST_TEXT[r.dest] })), + caption: "거리이정표", + empty: "", + }, + rows, + mode: `${withBase}:${anyPaved}`, + }; +} diff --git a/B08_Quantity/B08_Quantity_Summary_Table.ts b/B08_Quantity/B08_Quantity_Summary_Table.ts new file mode 100644 index 000000000..01a4ea70a --- /dev/null +++ b/B08_Quantity/B08_Quantity_Summary_Table.ts @@ -0,0 +1,245 @@ +/* ============================================================================= + * B08_Quantity_Summary_Table.ts + * 구조물 집계표(PLAN 61-1) 자료 읽기 · 표 짜기 — 화면 없음(PLAN 302-3). + * 화면(`B08_Quantity_UI_Summary`) · 인쇄 미리보기(`B09_Draft_Print_Quantity`)가 같은 함수를 씀. + * + * `loadSummary` = 구조물 · 배관 · 측점 간격 · 배관 집계(서버) → 줄 측점 · 조합 열 · 사본 경로 + * `summaryTable` = 공용 표 옵션(머리 · 측점 열 · 조합 열 · 배관 열 · 계 줄 · 램프 칸 class) + * 커플링 열 = 좌측 패널 1본 길이로 다시 셈(`view.live()` · 캐시 → 저장본 · PLAN 122) + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; +import type { DataColumn, DataRow, DataTableOptions } from "@ui/ui_template_data_table"; +import { pipeStructureId } from "@ui/ui_template_item_picker_project"; +import { fetchStructures } from "../B05_Profile/B05_Profile_Api_Structures"; +import { fetchLatestRoute, readRouteLatestCache } from "../B05_Profile/B05_Profile_Api_Fetch"; +import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { stationLabel } from "../common_util/common_util_station"; +import { summaryTooltip, type SummaryItem } from "../common_util/common_util_summary_item"; +import { + comboId, + linkedOnly, + pipeOwnerId, + summaryColumns, + summaryStations, + type ComboColumn, + type ComboLabel, + type LinkedSnapshot, + type PlacedItem, + type UnlinkedFacility, + unassignedText, + unlinkedAt, + widthOf, +} from "./B08_Quantity_Summary_Data"; +import { recountCouplings, type PipeLength } from "./B08_Quantity_Options_Calc"; +import { liveOnePipe } from "./B08_Quantity_UI_Options"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +/** 측점 간격 — 노선 설정값 · 못 읽으면 20m. */ +export async function stationInterval(projectId: string): Promise { + const latest = readRouteLatestCache(projectId) ?? (await fetchLatestRoute(projectId)); + return latest.route_params?.station_interval_m || 20; +} + +/** 서버 배관 집계(PLAN 98-1) — 열 머리 `group`(구조물 · 형식 · 규격) + `label`(단위) · 열마다 조합 id. */ +interface PipeColumn { + id: string; + group: string[]; + label: string; + combos: string[]; +} +export interface PipeSummary { + columns: PipeColumn[]; + rows: Array<{ chainage_m: number; values: Record }>; + total: Record; + notes: string[]; + /** 서버가 펼친 종류(`type_id` · 관 지점은 `facility`) — 조합 열에서 뺌 */ + types: string[]; + one_length_m: number | null; + pipes: PipeLength[]; + /** 연결된 사본 `{구조물 id: 사본}`(143-1) */ + linked: Record; + /** 미연결 시설(154-1) */ + unlinked: UnlinkedFacility[]; + /** 모든 시설 칸(모달 「조합 연결」 · 156-1) */ + facilities: UnlinkedFacility[]; +} + +async function fetchPipeSummary(projectId: string): Promise { + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/quantity/summary`, { + credentials: "include", + }); + if (!response.ok) throw new Error(`${response.status} ${await response.text()}`); + return response.json(); +} + +const PIPE_TOP = "관 공"; +/** 미연결 램프 칸 class(`B08_Quantity_UI_Page.css` · 198-1) · 램프 자리 폭(12px 글 기준) */ +export const LAMP_CLASS = "b08-summary__lamp"; +/** 모두 연결 = 같은 램프 초록(247-1) */ +const LINKED_CLASS = `${LAMP_CLASS} is-linked`; +const LAMP_W = 14; +const stationKey = (station: number): number => Math.round(station * 100); +/** 열 소수 자리 — 칸 · 계 값이 쓰는 만큼(최대 2). */ +const placesOf = (values: number[]): number => + Math.max(0, ...values.map((v) => (String(Math.round(v * 100) / 100).split(".")[1] ?? "").length)); + +/** 배관 열 → 표 열 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(사본 경로 · 조합 잇기 98). */ +function pipeColumns(summary: PipeSummary, comboPath: (id: string) => string | null): DataColumn[] { + const top = + summary.columns + .filter((column) => column.group[0] === PIPE_TOP) + .flatMap((column) => column.combos) + .map(comboPath) + .find((path) => !!path) ?? PIPE_TOP; + return summary.columns.map((column) => { + const group = column.group[0] === PIPE_TOP ? [top, ...column.group.slice(1)] : column.group; + const values = [ + ...summary.rows.map((row) => row.values[column.id]), + summary.total[column.id], + ].filter((value): value is number => typeof value === "number"); + const decimals = placesOf(values); + const texts = values.map((value) => + value.toLocaleString("en-US", { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }), + ); + return { + key: column.id, + label: column.label, + group, + kind: "number", + decimals, + title: [...group, column.label].join(" › "), + width: widthOf(column.label, ...texts), + }; + }); +} + +/** 집계표 한 벌 — 화면 · 인쇄가 함께 씀 */ +export interface SummaryView { + stations: number[]; + /** 조합 열(횡단배수 = 서버 배관 열이라 뺌) */ + columns: ComboColumn[]; + /** 모달용 구조물 · 관 지점(사본 있는 것) */ + things: PlacedItem[]; + linked: Record; + describe: (item: SummaryItem) => ComboLabel | null; + comboPath: (id: string) => string | null; + interval: number; + unlinked: UnlinkedFacility[]; + saved: PipeSummary | null; + /** 배관 집계 실패 까닭 — 나머지 표는 그대로 */ + error: string | null; + /** 지금 배관 집계 — 커플링 = 좌측 패널 1본 길이(캐시 → 저장본) */ + live: () => PipeSummary | null; +} + +export async function loadSummary(projectId: string): Promise { + const [structures, pipes, interval, pipe] = await Promise.all([ + fetchStructures(projectId), + // 배수 분석 전이면 관이 없음 — 구조물만으로 그림 + fetchDetailPipePoints(projectId).then( + (response) => response.pipe_points, + () => [], + ), + stationInterval(projectId).catch(() => 20), + // 배관 집계 실패 = 까닭 한 줄 · 나머지 표는 그대로 + fetchPipeSummary(projectId).catch((error: unknown) => String(error)), + ]); + const saved = typeof pipe === "string" ? null : pipe; + // 조합 이름 = 사본 manifest 경로(층 목록을 안 읽음 · 143-1) + const linked = saved?.linked ?? {}; + const paths = new Map( + Object.values(linked).map((copy) => [comboId(copy.항목), copy.경로] as const), + ); + const describe = (item: SummaryItem): ComboLabel | null => { + const [group = "", ...rest] = paths.get(comboId(item)) ?? []; + return group ? { group, name: rest.join(" ") || group } : null; + }; + const comboPath = (id: string): string | null => paths.get(id)?.join(" ") || null; + // 관 지점 id = 서버가 낸 id 먼저(응답 측점은 둘째 자리 · id 는 셋째 자리 · 167-1) + const ids = [...Object.keys(linked), ...(saved?.facilities ?? []).map((f) => f.id)]; + const placed = linkedOnly( + pipes.map((point) => ({ + ...point, + structure_id: pipeOwnerId(point.chainage_m, ids) ?? pipeStructureId(point.chainage_m), + })), + linked, + ); + const mine = linkedOnly(structures.structures, linked); + const things: PlacedItem[] = [...mine, ...placed]; + const unlinked = saved?.unlinked ?? []; + // 횡단배수는 서버 집계 열(98 · 120) — 조합 열은 다른 구조물 · 시설만(모달은 그대로 전부) + const served = new Set(saved?.types ?? []); + const others = [ + ...mine.filter((item) => !served.has(item.type_id)), + ...placed.filter((point) => !served.has(point.facility ?? "pipe")), + ]; + return { + stations: summaryStations([...things, ...unlinked]), + columns: summaryColumns(others, describe), + things, + linked, + describe, + comboPath, + interval, + unlinked, + saved, + error: typeof pipe === "string" ? pipe : null, + live: () => saved && recountCouplings(saved, liveOnePipe(projectId, saved.one_length_m)), + }; +} + +/** 표 옵션 — 줄 측점 · 측점 칸 램프 class · labels = 측점 글 · missing = 줄마다 빈 조합 칸 글(호버) */ +export function summaryTable(view: SummaryView): { + options: DataTableOptions; + labels: string[]; + missing: string[]; +} { + const { stations, columns, interval, unlinked, comboPath } = view; + const pipe = view.live(); + const values = new Map((pipe?.rows ?? []).map((row) => [stationKey(row.chainage_m), row.values])); + const total: DataRow | null = pipe?.columns.length + ? { station: L("B08_Quantity_Summary_Total"), ...pipe.total } + : null; + const labels = stations.map((station) => stationLabel(station, interval)); + const stationHead = L("B08_Quantity_Summary_Station"); + // 198-1 — 미연결 시설 측점 = 측점 칸 왼쪽 노랑 램프(값 빈칸 · 누르면 바로 연결 · 154) · 호버 = 빈 조합 칸(160-2) + const missing = stations.map((station) => + unassignedText(unlinkedAt(unlinked, station), L("B08_Quantity_Link_Body")), + ); + // 247-1 — 시설이 있고 빈 칸 없음 = 초록 램프 + const linked = stations.map((station) => unlinkedAt(pipe?.facilities ?? [], station).length > 0); + const options: DataTableOptions = { + columns: [ + { key: "station", label: stationHead, width: widthOf(stationHead, ...labels) + LAMP_W }, + ...columns.map((column) => ({ + key: column.id, + label: column.name, + group: [column.group || L("B08_Quantity_Summary_MissingGroup")], + title: summaryTooltip(column.item), + className: column.missing ? "is-missing" : undefined, + width: widthOf(column.name), + })), + ...(pipe ? pipeColumns(pipe, comboPath) : []), + ], + // 조합 열 칸은 아직 비움 — 사용자가 채울 자리라 「-」 대신 빈칸 · 배관 칸 = 서버 값 + rows: stations.map((station, i) => ({ + station: labels[i], + ...values.get(stationKey(station)), + })), + total, + cellClass: (i, key) => + key !== "station" ? null : missing[i] ? LAMP_CLASS : linked[i] ? LINKED_CLASS : null, + frozen: 1, + empty: "", + caption: L("B08_Quantity_Tab_StructureSummary"), + }; + return { options, labels, missing }; +} diff --git a/B08_Quantity/B08_Quantity_UI_Earthwork.ts b/B08_Quantity/B08_Quantity_UI_Earthwork.ts index c29233cc3..071d23d3c 100644 --- a/B08_Quantity/B08_Quantity_UI_Earthwork.ts +++ b/B08_Quantity/B08_Quantity_UI_Earthwork.ts @@ -9,142 +9,24 @@ * 표 = 공용 많은 자료 표(`ui_template_data_table` · PLAN 70) · 카드 안 · 나머지 셋은 `…_Earthwork_Sheets`. * 절토 연암 · 경암 = 암 × 좌측 패널 비율 · 암 C = 비율 가중 — 조작 중 같은 식으로 바로 다시 셈 * (`…_Options_Calc` · PLAN 122 · 125-1). + * 자료 읽기 · 표 짜기 = `B08_Quantity_Earthwork_Tables`(화면 없음 · 인쇄 미리보기도 씀 · 302-3) * ========================================================================== */ -import { API_BASE_URL } from "@config/config_frontend"; import { createButton, createCard } from "@ui/ui_template_elements"; -import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table"; -import { stationLabel } from "../common_util/common_util_station"; +import { createDataTable } from "@ui/ui_template_data_table"; import { footNote } from "./B08_Quantity_UI_Foot"; -import { stationInterval } from "./B08_Quantity_UI_Summary"; +import { + EARTHWORK_SHEETS, + earthworkSheetOptions, + loadEarthwork, + type EarthworkTable, +} from "./B08_Quantity_Earthwork_Tables"; import { renderEarthworkSummary, renderFreeHaul, renderHaulDistance, } from "./B08_Quantity_UI_Earthwork_Sheets"; -import { - mixedRockC, - recountAdjustedSummary, - recountRockC, - splitRockColumns, - splitRockSummary, -} from "./B08_Quantity_Options_Calc"; -import { liveSoftRock, onOptionsChange } from "./B08_Quantity_UI_Options"; - -type Face = { length_m: number | null; area_m2: number | null }; - -interface EarthworkRow { - chainage_m: number; - distance_m: number; - cut_soil_area_m2: number; - cut_soil_volume_m3: number; - cut_soil_adjusted_m3: number; - cut_rock_area_m2: number; - cut_rock_volume_m3: number; - cut_rock_adjusted_m3: number; - cut_rock_kind: string; - ditch_soil_area_m2: number; - ditch_soil_volume_m3: number; - ditch_soil_adjusted_m3: number; - ditch_rock_area_m2: number; - ditch_rock_volume_m3: number; - ditch_rock_adjusted_m3: number; - adjusted_total_m3: number; - fill_area_m2: number; - fill_volume_m3: number; - diverted_m3: number; - balance_m3: number; - cumulative_m3: number; - notes: string[]; - faces: Record; -} - -export interface HaulRow { - q: number; - l: number; - ql: number; -} - -export interface HaulList { - rows: HaulRow[]; - q: number; - ql: number; - average_m: number | null; -} - -export interface SummaryRow { - group: string; - kind: string; - spec: string; - unit: string; - value: number | null; - note: string; -} - -export interface EarthworkTable { - rows: EarthworkRow[]; - totals: Record; - conversion_factors: Record; - station_count: number; - route_id: number; - confirmed: boolean; - summary: SummaryRow[]; - haul: Record<"dump_truck" | "dozer" | "free_haul", HaulList>; - haul_saved: boolean; - /** 저장된 연암 · 경암 %(암 전체 기준) */ - rock_ratios_pct: { 연암: number; 경암: number }; - /** 가중 전 리핑암(연암) · 발파암(경암) C — 비율로 다시 가중(125-1) */ - rock_c: { ripping_rock: number; blasting_rock: number }; -} - -/** 칸 묶음 — [머리 묶음, 키 앞머리, 풍선 글] · 단면적 · 입적 · 보정량 셋 · 머리 글자는 사용자 견본대로 */ -const TRIPLES: Array<[string[], string, string?]> = [ - [["절토", "토사"], "cut_soil"], - [["절토", "연암"], "cut_soft_rock", "암 × 연암 %"], - [["절토", "경암"], "cut_hard_rock", "암 × 경암 %"], - [["측구파기", "토사"], "ditch_soil"], - [["측구파기", "암"], "ditch_rock", "리핑암 · 발파암 합"], -]; -/** 면 계열 — [머리 묶음, 키] · 거리 · 면적 둘 */ -const FACES: Array<[string[], string]> = [ - [["면고르기", "성토면"], "grading_fill"], - [["면고르기", "절토면"], "grading_cut"], - [["지장목제거", "성토"], "tree_removal_fill"], - [["지장목제거", "절토"], "tree_removal_cut"], - [["제근"], "grubbing"], - [["노면정리"], "road_surface"], -]; - -const n2 = (key: string, label: string, group?: string[]): DataColumn => ({ - key, - label, - group, - kind: "number", - decimals: 2, -}); - -/** 견본 차례 32칸 */ -const COLUMNS: DataColumn[] = [ - { key: "station", label: "측점", width: 72 }, - n2("distance", "거리"), - ...TRIPLES.flatMap(([group, key, tip]) => - [ - n2(`${key}_area`, "단면적", group), - n2(`${key}_volume`, "입적", group), - n2(`${key}_adjusted`, "보정량", group), - ].map((c) => (tip ? { ...c, title: `${group.at(-1)} ${c.label} — ${tip}` } : c)), - ), - n2("adjusted_total", "보정량계"), - n2("fill_area", "단면적", ["성토"]), - n2("fill_volume", "입적", ["성토"]), - n2("diverted", "유용토"), - n2("balance", "차인토량"), - n2("cumulative", "누가토량"), - ...FACES.flatMap(([group, key]) => [ - n2(`${key}_length`, "거리", group), - n2(`${key}_area`, "면적", group), - ]), -]; +import { onOptionsChange } from "./B08_Quantity_UI_Options"; export function note(text: string): HTMLElement { const p = document.createElement("p"); @@ -162,56 +44,6 @@ export function tableCard( return createCard({ body: [table, ...after, ...info] }); } -function stationRow(row: EarthworkRow, first: boolean, interval: number): DataRow { - // 첫 측점은 앞이 없어 입적 · 보정량 · 면적이 없음(빈 값) - const v = (value: number): number | null => (first ? null : value); - const out: DataRow = { - station: stationLabel(row.chainage_m, interval) + (row.notes.length ? " *" : ""), - distance: row.distance_m, - adjusted_total: v(row.adjusted_total_m3), - fill_area: row.fill_area_m2, - fill_volume: v(row.fill_volume_m3), - diverted: v(row.diverted_m3), - balance: v(row.balance_m3), - cumulative: v(row.cumulative_m3), - }; - const r = row as unknown as Record; - for (const [, key] of TRIPLES) { - out[`${key}_area`] = r[`${key}_area_m2`]; - out[`${key}_volume`] = v(r[`${key}_volume_m3`]); - out[`${key}_adjusted`] = v(r[`${key}_adjusted_m3`]); - } - for (const [, key] of FACES) { - out[`${key}_length`] = row.faces?.[key]?.length_m ?? null; - out[`${key}_area`] = first ? null : (row.faces?.[key]?.area_m2 ?? null); - } - return out; -} - -/** 합계 줄 — 단면적 · 거리는 합이 뜻이 없어 비움(옛 엔진 `totals` 와 같음) · 누가토량 = 마지막 줄 */ -function totalRow(table: EarthworkTable): DataRow { - const t = table.totals; - const out: DataRow = { - station: "합계", - distance: t.distance_m, - adjusted_total: t.adjusted_total_m3, - fill_volume: t.fill_volume_m3, - diverted: t.diverted_m3, - balance: t.balance_m3, - cumulative: table.rows[table.rows.length - 1]?.cumulative_m3, - }; - for (const [, key] of TRIPLES) { - out[`${key}_volume`] = t[`${key}_volume_m3`]; - out[`${key}_adjusted`] = t[`${key}_adjusted_m3`]; - } - // 값이 하나도 없는 칸(제근 — B07 에 값 없음)은 합계도 비움 - for (const [, key] of FACES) { - const any = table.rows.some((row) => row.faces?.[key]?.area_m2 != null); - out[`${key}_area`] = any ? t[`${key}_area_m2`] : null; - } - return out; -} - function renderSheet(host: HTMLElement, table: EarthworkTable, interval: number): void { if (!table.rows.length) { host.replaceChildren(note("측점 단면적이 아직 없습니다 — 횡단 설계를 먼저 마치세요.")); @@ -225,66 +57,25 @@ function renderSheet(host: HTMLElement, table: EarthworkTable, interval: number) ), ...(flagged.length ? [footNote(`* ${flagged[0].notes[0]} — ${flagged.length}곳`)] : []), ]; - const grid = createDataTable({ - columns: COLUMNS, - rows: table.rows.map((row, i) => stationRow(row, i === 0, interval)), - frozen: 1, - total: totalRow(table), - caption: "토적표", - fillViewport: true, - }); + const grid = createDataTable({ ...earthworkSheetOptions(table, interval), fillViewport: true }); grid.root.dataset.b08Earthwork = "table"; host.replaceChildren(tableCard(info, grid.root)); } -const SHEETS = [ - { id: "summary", label: "토공집계표" }, - { id: "sheet", label: "토적표" }, - { id: "haul", label: "평균운반거리" }, - { id: "free", label: "종방향 무대운반거리" }, -] as const; +const SHEETS = EARTHWORK_SHEETS; type SheetId = (typeof SHEETS)[number]["id"]; export async function renderEarthwork(host: HTMLElement, projectId: string): Promise { host.replaceChildren(note("토공 수량을 만드는 중…")); - let table: EarthworkTable; - let interval = 20; + let loaded: Awaited>; try { - const [response, gap] = await Promise.all([ - fetch(`${API_BASE_URL}/projects/${projectId}/quantity/earthwork`, { credentials: "include" }), - stationInterval(projectId).catch(() => 20), - ]); - const body = (await response.json().catch(() => null)) as - (EarthworkTable & { detail?: unknown }) | null; - if (!response.ok || !body) { - const detail = body?.detail; - throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`); - } - table = body; - interval = gap; + loaded = await loadEarthwork(projectId); } catch (error) { host.replaceChildren(note(`토공 수량을 불러오지 못했습니다 — ${String(error)}`)); return; } - - // 연암 · 경암 칸 · 집계표 줄 = 암 × 지금 비율 · 암 C = 비율 가중(캐시 → 저장본 · 125-1) - const summary = table.summary; - const applyRock = (): void => { - const soft = liveSoftRock(projectId, table.rock_ratios_pct?.연암 ?? 90); - const c = mixedRockC(soft, table.rock_c.ripping_rock, table.rock_c.blasting_rock); - for (const kind of ["ripping_rock", "blasting_rock"]) - table.conversion_factors[kind].compacted = c; - recountRockC(table.rows as unknown as Array>, table.totals, c); - for (const record of [...table.rows, table.totals]) - splitRockColumns(record as unknown as Record, soft); - table.summary = recountAdjustedSummary( - splitRockSummary(summary, table.totals, soft), - table.totals, - table.haul.free_haul.q, - ); - }; - applyRock(); + const { table, interval, applyRock } = loaded; const tabs = document.createElement("div"); tabs.className = "b08-quantity__tabs ui-subtabs b08-earthwork__tabs"; diff --git a/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts b/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts index ee9e58c22..372561c31 100644 --- a/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts +++ b/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts @@ -5,45 +5,25 @@ * 칸 · 식 = 거창 실무 워크북 같은 이름 시트 그대로 · 값은 서버가 셈(`…_Engine_Earthwork_Summary`). * 운반 Q · L = 저장된 유토곡선 배분(B07 토적도와 같은 정본) — 없으면 그 사유만 보임. * 표 = 공용 많은 자료 표(`ui_template_data_table`). + * 표 짜기 = `B08_Quantity_Earthwork_Tables`(화면 없음 · 인쇄 미리보기도 씀 · 302-3) * ========================================================================== */ -import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table"; -import { note, tableCard, type EarthworkTable, type HaulList } from "./B08_Quantity_UI_Earthwork"; +import { createDataTable } from "@ui/ui_template_data_table"; +import { note, tableCard } from "./B08_Quantity_UI_Earthwork"; +import { + earthworkSummaryOptions, + HAUL_SHEETS, + haulOptions, + type EarthworkTable, +} from "./B08_Quantity_Earthwork_Tables"; import { footNote } from "./B08_Quantity_UI_Foot"; -const fmt = (value: number | null | undefined): string => - value == null || !Number.isFinite(value) - ? "-" - : value.toLocaleString("ko-KR", { maximumFractionDigits: 2 }); - -const SUMMARY_COLUMNS: DataColumn[] = [ - { key: "group", label: "구분", width: 120 }, - { key: "kind", label: "공종", width: 72 }, - { key: "spec", label: "규격", width: 128 }, - { key: "unit", label: "단위", width: 56 }, - { key: "value", label: "계", kind: "number", decimals: 2, width: 112 }, - { key: "note", label: "비고", width: 200 }, -]; - /** 토공집계표 — 구분 · 공종 · 규격 · 단위 · 계 · 비고 */ export function renderEarthworkSummary(host: HTMLElement, table: EarthworkTable): void { const info = footNote( "연암 · 경암은 좌측 패널 비율대로 나눈 암 · 「비율 미정」은 비율 확인 뒤 채움", ); - const grid = createDataTable({ - columns: SUMMARY_COLUMNS, - // 칸이 없는 글(공종 · 규격 · 비고)은 빈칸 — 공용 표는 "" 를 「-」(값 없음)로 그림 - rows: table.summary.map((row) => ({ - ...row, - kind: row.kind || " ", - spec: row.spec || " ", - note: row.note || " ", - })), - frozen: 1, - mergeSame: ["group", "spec"], // 201-1 같은 구분 · 규격 이어지면 세로 병합(빈칸은 안 합침) - caption: "토공집계표", - fillViewport: true, - }); + const grid = createDataTable({ ...earthworkSummaryOptions(table), fillViewport: true }); host.replaceChildren(tableCard([info], grid.root)); } @@ -53,74 +33,22 @@ function haulNote(table: EarthworkTable): HTMLElement[] { : [footNote("유토곡선 배분이 저장되지 않았습니다 — 횡단 설계 [확정] 뒤에 채워집니다.")]; } -/** 운반 시트 한 장 — 묶음마다 토량(Q) · 거리(L) · Q×L · 맨 아래 합계 · 아래 평균운반거리 줄 */ +/** 운반 시트 한 장 — 표 + 아래 평균운반거리 줄 */ function haulSheet( host: HTMLElement, table: EarthworkTable, - groups: Array<[string, HaulList]>, - caption: string, + sheet: keyof typeof HAUL_SHEETS, ): void { - const columns: DataColumn[] = [ - { key: "no", label: "번호", width: 56 }, - ...groups.flatMap(([label], g) => - [ - ["q", "토량(Q)"], - ["l", "거리(L)"], - ["ql", "Q×L"], - ].map(([key, text]) => ({ - key: `${key}${g}`, - label: text, - group: [label], - kind: "number" as const, - decimals: 2, - width: 104, - })), - ), - ]; - const count = Math.max(0, ...groups.map(([, list]) => list.rows.length)); - const rows: DataRow[] = Array.from({ length: count }, (_, i) => { - const out: DataRow = { no: String(i + 1) }; - groups.forEach(([, list], g) => { - const item = list.rows[i]; - if (item) Object.assign(out, { [`q${g}`]: item.q, [`l${g}`]: item.l, [`ql${g}`]: item.ql }); - }); - return out; - }); - const total: DataRow = { no: "합계" }; - groups.forEach(([, list], g) => Object.assign(total, { [`q${g}`]: list.q, [`ql${g}`]: list.ql })); - const averages = groups.map(([label, list], i) => - note( - `${i + 1}) ${label} 평균운반거리: ${fmt(list.ql)} ÷ ${fmt(list.q)} = ${fmt(list.average_m)} M`, - ), - ); - const grid = createDataTable({ - columns, - rows, - frozen: 1, - total, - caption, - fillViewport: true, - }); - host.replaceChildren(tableCard(haulNote(table), grid.root, averages)); + const { groups, caption } = HAUL_SHEETS[sheet](table); + const { options, averages } = haulOptions(groups, caption); + const grid = createDataTable({ ...options, fillViewport: true }); + host.replaceChildren(tableCard(haulNote(table), grid.root, averages.map(note))); } export function renderHaulDistance(host: HTMLElement, table: EarthworkTable): void { - haulSheet( - host, - table, - [ - ["덤프트럭", table.haul.dump_truck], - ["불도우저", table.haul.dozer], - ], - "덤프트럭 · 불도우저 평균운반거리 — 단위 ㎥ · m", - ); + haulSheet(host, table, "haul"); } export function renderFreeHaul(host: HTMLElement, table: EarthworkTable): void { - haulSheet( - host, - table, - [["종방향무대", table.haul.free_haul]], - "종방향 무대운반거리 — 단위 ㎥ · m", - ); + haulSheet(host, table, "free"); } diff --git a/B08_Quantity/B08_Quantity_UI_Hydro.ts b/B08_Quantity/B08_Quantity_UI_Hydro.ts index e33d2009b..c835d8c85 100644 --- a/B08_Quantity/B08_Quantity_UI_Hydro.ts +++ b/B08_Quantity/B08_Quantity_UI_Hydro.ts @@ -8,55 +8,32 @@ * 처음 값 = 표준 값 · 측점 고유 값은 빈칸 · 지역 · R24 = 공사지 자료(서버) * 조작 = 캐시(sessionStorage) · [저장] = B08 자기 파일(PUT …/quantity/hydro) · 뒤 캐시 비움 * 계산 = 서버 단독(POST …/quantity/hydro/calc · 최신 응답만 채택) + * 자료 읽기 · 측점 입력 겹치기 = `B08_Quantity_Hydro_Table`(화면 없음 · 인쇄 미리보기도 씀 · 302-3) * ========================================================================== */ import "./B08_Quantity_UI_Hydro.css"; -import { API_BASE_URL } from "@config/config_frontend"; import { createButton, hideLoadingOverlay, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; -import { clearState, readState, writeState } from "../A00_Common/b_page_state"; +import { clearState, writeState } from "../A00_Common/b_page_state"; import { stationLabel } from "../common_util/common_util_station"; -import { stationInterval } from "./B08_Quantity_UI_Summary"; import { - buildHydroSheet, - type HydroInputs, - type HydroLink, - type HydroOptions, - type HydroResult, -} from "./B08_Quantity_UI_Hydro_Sheet"; + hydroCalc, + hydroInputs, + keyOf, + loadHydro, + unlinkedInputs as unlinked, + type Drafts, + type HydroPayload, + type HydroStation, +} from "./B08_Quantity_Hydro_Table"; +import { request } from "./B08_Quantity_Material_Tables"; +import { buildHydroSheet, type HydroInputs, type HydroResult } from "./B08_Quantity_UI_Hydro_Sheet"; -interface HydroStation { - chainage_m: number; - facility: string; - /** 박스암거 · 세월교 · 물넘이포장(관이면 "") */ - kind: string; - /** 계산서 장 — 시설이 정함(서버 station_rows) */ - sheet: HydroInputs["sheet"]; - /** B05 연결값(값이 있는 칸만) · 비교용 B05 Qd · T — 서버 station_rows */ - linked: HydroLink["values"] | null; - b05: HydroLink["b05"]; -} - -type Drafts = Record>; -type HydroPayload = { stations: HydroStation[]; saved: Drafts } & HydroOptions; - -export async function request(path: string, init: RequestInit = {}): Promise { - const response = await fetch(`${API_BASE_URL}${path}`, { - credentials: "include", - headers: { "Content-Type": "application/json" }, - ...init, - }); - const body = (await response.json().catch(() => null)) as { detail?: unknown } | null; - if (!response.ok) { - const detail = body?.detail; - throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`); - } - return body as T; -} +export { request }; function note(text: string): HTMLElement { const p = document.createElement("p"); @@ -65,17 +42,13 @@ function note(text: string): HTMLElement { return p; } -const keyOf = (station: HydroStation): string => station.chainage_m.toFixed(3); - export async function renderHydro(host: HTMLElement, projectId: string): Promise { host.replaceChildren(note("홍수량 계산서를 불러오는 중…")); let sheet: HydroPayload; let interval = 20; + let drafts: Drafts = {}; try { - [sheet, interval] = await Promise.all([ - request(`/projects/${projectId}/quantity/hydro`), - stationInterval(projectId).catch(() => 20), - ]); + ({ sheet, interval, drafts } = await loadHydro(projectId)); } catch (error) { host.replaceChildren(note(`홍수량 계산서를 불러오지 못했습니다 — ${String(error)}`)); return; @@ -87,27 +60,7 @@ export async function renderHydro(host: HTMLElement, projectId: string): Promise return; } - let drafts: Drafts = readState("b08-hydro", projectId) ?? {}; - const linkedOf = (station: HydroStation): Record => station.linked ?? {}; - /** 저장본 · 초안 한 겹 — 연결값이 있는 칸의 null 은 건너뜀(연결값을 덮지 않게) */ - const layer = (station: HydroStation, values: Partial = {}) => - Object.fromEntries( - Object.entries(values).filter(([k, v]) => v != null || !(k in linkedOf(station))), - ); - const inputsOf = (station: HydroStation): HydroInputs => - ({ - ...sheet.defaults, - ...(station.sheet === "pipe" ? {} : sheet.sheet_defaults[station.sheet]), - ...station.linked, - // 초안은 측점 입력 통째 — 있으면 저장본 대신(초안의 null = 연결값으로 되돌림) - ...layer(station, drafts[keyOf(station)] ?? sheet.saved[keyOf(station)]), - sheet: station.sheet, - }) as HydroInputs; - /** 연결값과 같은 칸은 null — 저장 · 초안이 연동을 유지(B05 값이 바뀌면 따라옴) · 다르면 손 값 */ - const unlinked = (station: HydroStation, inputs: HydroInputs): HydroInputs => - Object.fromEntries( - Object.entries(inputs).map(([k, v]) => [k, linkedOf(station)[k] === v ? null : v]), - ) as unknown as HydroInputs; + const inputsOf = (station: HydroStation): HydroInputs => hydroInputs(sheet, station, drafts); const tabs = document.createElement("div"); tabs.className = "b08-quantity__tabs ui-subtabs b08-hydro__tabs"; @@ -172,10 +125,7 @@ export async function renderHydro(host: HTMLElement, projectId: string): Promise ): Promise => { const mine = ++seq; try { - const result = await request(`/projects/${projectId}/quantity/hydro/calc`, { - method: "POST", - body: JSON.stringify(inputs), - }); + const result = await hydroCalc(projectId, inputs); if (mine === seq) show(result); } catch (error) { if (mine === seq) show(null, `계산하지 못했습니다 — ${String(error)}`); diff --git a/B08_Quantity/B08_Quantity_UI_Material.ts b/B08_Quantity/B08_Quantity_UI_Material.ts index 849d7b6ea..b7f7c984f 100644 --- a/B08_Quantity/B08_Quantity_UI_Material.ts +++ b/B08_Quantity/B08_Quantity_UI_Material.ts @@ -11,9 +11,9 @@ * 콘크리트소요는 5줄 늘 보임(수량 빈칸 · 배합 늘 고침) + 콘크리트 전용 빈 까닭 한 줄(163-5) * 콘크리트(기계) · 모르터 1:3 열 = 늘 보임 · 손 단위수량 → 콘크리트소요(160-4) · 엑셀 손값과 다른 * 배합 · 할증은 표 아래 알림 한 줄만 · 맞춤 없는 사본 자재 · 채집석 공제 = 총괄 끝 줄(160-3 · 5) + * 자료 읽기 · 표 짜기 = `B08_Quantity_Material_Tables`(화면 없음 · 인쇄 미리보기도 씀 · 302-3) * ========================================================================== */ -import { API_BASE_URL } from "@config/config_frontend"; import { createButton, createCard, @@ -21,36 +21,30 @@ import { showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; -import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table"; -import { clearState, readState, writeState } from "../A00_Common/b_page_state"; +import { createDataTable } from "@ui/ui_template_data_table"; +import { clearState, writeState } from "../A00_Common/b_page_state"; import { blankUnitRows, - computeMaterialSheets, - DEDUCT_KEY, matched, MIX_FIELDS, type Cells, - type MaterialOverrides, type MaterialSheets, type SummarySource, } from "./B08_Quantity_Material_Calc"; +import { + loadMaterials, + materialOptions, + materialTable, + MIX_LABEL, + request, + type MaterialSheetId, + type Payload, +} from "./B08_Quantity_Material_Tables"; import { footNote } from "./B08_Quantity_UI_Foot"; -import { liveSurcharge, onOptionsChange } from "./B08_Quantity_UI_Options"; +import { onOptionsChange } from "./B08_Quantity_UI_Options"; -export type MaterialSheetId = "material" | "concrete" | "quantity"; +export type { MaterialSheetId }; -interface Payload { - source: SummarySource; - overrides: MaterialOverrides; - /** 좌측 패널 할증 저장본(기본 채움) */ - surcharge_pct: Cells; - /** 구조물 집계표 알림(「집수정 모양 기록 없음 n곳」 등) — 총괄 · 콘크리트 위 빈 까닭(131) */ - notes?: string[]; - sheets: MaterialSheets; -} - -const BASIS = - "구조물 수량 기준 — 일위대가 재료와 별개(두 번 세지 않게 일위대가 재료배합과 겹치지 않는지 확인)"; const HINTS: Record = { material: "할증 %는 칸에서 고침 · 모래 · 자갈 · 시멘트는 좌측 패널", concrete: "㎥당 배합은 칸에서 고침", @@ -58,25 +52,6 @@ const HINTS: Record = { }; const HIDDEN = "값 없는 줄은 숨김"; -/** 보일 줄 차례(원래 줄 번호) — 값 없는 줄은 숨김(143-3) */ -const shown = (rows: readonly T[], has: (row: T) => boolean): number[] => - rows.flatMap((row, i) => (has(row) ? [i] : [])); -const anyValue = (cells: Cells): boolean => Object.values(cells).some((v) => v != null); - -async function request(path: string, init: RequestInit = {}): Promise { - const response = await fetch(`${API_BASE_URL}${path}`, { - credentials: "include", - headers: { "Content-Type": "application/json" }, - ...init, - }); - const body = (await response.json().catch(() => null)) as { detail?: unknown } | null; - if (!response.ok) { - const detail = body?.detail; - throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`); - } - return body as T; -} - function note(text: string): HTMLElement { const p = document.createElement("p"); p.className = "ui-general-block__empty"; @@ -84,14 +59,6 @@ function note(text: string): HTMLElement { return p; } -/** 숫자 열 — 최대 3 자리 · 끝 0 지움(고친 값 자리가 줄마다 다름) */ -const numberColumn = (column: DataColumn): DataColumn => ({ - kind: "number", - decimals: 3, - trim: true, - ...column, -}); - /** 글 → 값 · 빈칸 = null(일부러 비움) · 수가 아니면 undefined(무시) */ function parse(text: string): number | null | undefined { const t = text.replace(/,/g, "").trim(); @@ -100,70 +67,6 @@ function parse(text: string): number | null | undefined { return Number.isFinite(v) ? v : undefined; } -interface Table { - columns: DataColumn[]; - rows: DataRow[]; - total: DataRow | null; - frozen: number; - editable: (index: number, key: string) => boolean; - /** 고친 칸 → 사용자 값 자리 [표, 줄 키, 칸 키] */ - target: (index: number, key: string) => [Record | Cells, string, string | null]; - /** 같은 값 세로 병합 열(201-1 · 공용 표 mergeSame) */ - mergeSame?: string[]; -} - -function quantityTable(s: MaterialSheets, o: MaterialOverrides): Table { - const { materials, rows, total } = s.quantity; - const keep = shown(rows, (row) => row.qty != null || anyValue(row.units)); - const rowsOut: DataRow[] = keep.flatMap((at) => { - const row = rows[at]; - const unit: DataRow = { - name: row.name, - spec: row.spec, - qty: row.qty, - unit: row.unit, - kind: "단위수량", - }; - const amount: DataRow = { kind: "수량" }; - for (const m of materials) { - unit[`m:${m.key}`] = row.units[m.key]; - amount[`m:${m.key}`] = row.amounts[m.key]; - } - return [unit, amount]; - }); - const totalRow: DataRow = { name: "계", kind: "수량" }; - for (const m of materials) totalRow[`m:${m.key}`] = total[m.key]; - return { - columns: [ - { key: "name", label: "구분", width: 120 }, - { key: "spec", label: "규격", width: 120 }, - numberColumn({ key: "qty", label: "수량", width: 72 }), - { key: "unit", label: "단위", width: 56 }, - { key: "kind", label: "구분", width: 72 }, - ...materials.map((m) => - numberColumn({ - key: `m:${m.key}`, - label: m.unit, - group: m.spec ? [m.name, m.spec] : [m.name], - title: [m.name, m.spec, m.unit].filter(Boolean).join(" › "), - width: 76, - }), - ), - ], - rows: rowsOut, - total: totalRow, - frozen: 5, - editable: (i, key) => i % 2 === 0 && key.startsWith("m:"), - target: (i, key) => [o.units, rows[keep[i / 2]].id, key.slice(2)], - }; -} - -const MIX_LABEL: Record<(typeof MIX_FIELDS)[number], [string, string, string]> = { - cement: ["시멘트", "㎥당(kg)", "수량(kg)"], - sand: ["모래", "㎥당(㎥)", "수량(㎥)"], - gravel: ["자갈", "㎥당(㎥)", "수량(㎥)"], -}; - /** 2공구 엑셀 손값(조사 I) — 지금 값(품셈 근거 · 124)과 다르면 표 아래 알림 한 줄만(160-4) */ const EXCEL_MIX: Record = { c25: { cement: 346, sand: 0.51, gravel: 0.59 }, @@ -214,100 +117,6 @@ function concreteWhy(s: MaterialSheets, source: SummarySource): string { : ""; } -function concreteTable(s: MaterialSheets, o: MaterialOverrides): Table { - const { rows, total } = s.concrete; - const keep = rows.map((_row, i) => i); // 5줄 늘 보임(163-5) — 수량 없으면 빈칸 · 배합은 늘 고침 - const rowsOut: DataRow[] = keep.map((at) => { - const row = rows[at]; - const out: DataRow = { - name: row.name, // 위 줄과 같으면 세로 병합(201-1) - spec: row.spec, - qty: row.qty, - }; - for (const f of MIX_FIELDS) { - out[`per:${f}`] = row.per[f]; - out[`amt:${f}`] = row.amounts[f]; - } - return out; - }); - const totalRow: DataRow = { name: "계" }; - for (const f of MIX_FIELDS) totalRow[`amt:${f}`] = total[f]; - return { - columns: [ - { key: "name", label: "구분", width: 88 }, - { key: "spec", label: "규격", width: 110 }, - numberColumn({ key: "qty", label: "수량(㎥)", width: 80 }), - ...MIX_FIELDS.flatMap((f) => [ - numberColumn({ - key: `per:${f}`, - label: MIX_LABEL[f][1], - group: [MIX_LABEL[f][0]], - width: 84, - }), - numberColumn({ - key: `amt:${f}`, - label: MIX_LABEL[f][2], - group: [MIX_LABEL[f][0]], - width: 96, - }), - ]), - ], - rows: rowsOut, - total: totalRow, - frozen: 2, - mergeSame: ["name"], - editable: (_i, key) => key.startsWith("per:"), - target: (i, key) => [o.mix, rows[keep[i]].key, key.slice(4)], - }; -} - -function totalTable(s: MaterialSheets, o: MaterialOverrides): Table { - const { rows } = s.material; - const lost = new Set(s.quantity.unmatched); - const keep = shown(rows, (row) => row.qty != null || row.amount != null); - return { - columns: [ - { key: "group", label: "구분", width: 96 }, - { key: "spec", label: "규격", width: 120 }, - { key: "unit", label: "단위", width: 56 }, - numberColumn({ key: "qty", label: "수량", width: 96 }), - numberColumn({ key: "surcharge", label: "할증(%)", width: 72 }), - numberColumn({ key: "amount", label: "합계", width: 96 }), - { key: "note", label: "비고", width: 96 }, - ], - rows: keep.map((at) => { - const row = rows[at]; - return { - group: row.group, // 위 줄과 같으면 세로 병합(201-1) - spec: row.spec, - unit: row.unit, - qty: row.qty, - surcharge: row.surcharge, - amount: row.amount, - note: row.panel - ? "할증 = 좌측 패널" - : row.key === DEDUCT_KEY - ? "토공 잔토처리에서 뺌" - : lost.has(row.key) - ? "2공구 맞춤 없음" - : "", - }; - }), - total: null, - frozen: 2, - mergeSame: ["group"], - editable: (i, key) => - key === "surcharge" && !rows[keep[i]].panel && rows[keep[i]].key !== DEDUCT_KEY, - target: (i) => [o.surcharge, rows[keep[i]].key, null], - }; -} - -const BUILD: Record Table> = { - material: totalTable, - concrete: concreteTable, - quantity: quantityTable, -}; - /** 보조 탭 한 장 — 불러오는 동안 한 줄 · 실패하면 까닭 한 줄. */ export async function renderMaterialSheet( host: HTMLElement, @@ -315,21 +124,15 @@ export async function renderMaterialSheet( sheet: MaterialSheetId, ): Promise { host.replaceChildren(note("자재 집계표를 불러오는 중…")); - let payload: Payload; + let loaded: Awaited>; try { - payload = await request(`/projects/${projectId}/quantity/materials`); + loaded = await loadMaterials(projectId); } catch (error) { host.replaceChildren(note(`자재 집계표를 불러오지 못했습니다 — ${String(error)}`)); return; } - const draft = readState("b08-materials", projectId); - let overrides: MaterialOverrides = draft ?? payload.overrides; - const calc = (): MaterialSheets => - computeMaterialSheets( - payload.source, - overrides, - liveSurcharge(projectId, payload.surcharge_pct), - ); + let { payload, overrides } = loaded; + const calc = (): MaterialSheets => loaded.calc(payload, overrides); let sheets = calc(); const save = createButton({ label: "저장", variant: "filled", onClick: () => void saveAll() }); @@ -356,15 +159,9 @@ export async function renderMaterialSheet( if (sheet === "concrete") notes.append(concreteNote); toolbar.append(save); - let built = BUILD[sheet](sheets, overrides); + let built = materialTable(sheet, sheets, overrides); const table = createDataTable({ - columns: built.columns, - rows: built.rows, - total: built.total, - frozen: built.frozen, - mergeSame: built.mergeSame, - empty: "", - caption: BASIS, + ...materialOptions(built), fillViewport: true, editable: (i, key) => built.editable(i, key), editOnDouble: true, // 243-1 값은 더블클릭 · Enter · F2 로만 고침 @@ -388,7 +185,7 @@ export async function renderMaterialSheet( const excel = footNote(""); excel.dataset.b08MaterialExcel = ""; const redraw = (): void => { - built = BUILD[sheet](sheets, overrides); + built = materialTable(sheet, sheets, overrides); table.setRows(built.rows, built.total); excel.textContent = sheet === "quantity" ? "" : excelDiffs(sheets); concreteNote.textContent = concreteWhy(sheets, payload.source); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.ts b/B08_Quantity/B08_Quantity_UI_Milestone.ts index 09a8f979d..1db32d83e 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone.ts @@ -12,6 +12,7 @@ * 단가연결(251-4) = 항목 칸 앞 램프(초록 = 연결 · 노랑 = 없음 · 호버 = 무엇) · 줄 우클릭 [단가연결] · * [단가연결 풀기] — 창 = 연결 없음 → 단가연결 목록 → 옵션 값 창(276 · 옛 길) · 연결 있음 → * 「운반 단가」 모달(253-3 · B09 운반공 줄과 같음 · 그 행 거리) · 경로 `ref` · B09 운반공 줄이 받음 + * 표 열 · 줄 짜기 = `B08_Quantity_Milestone_Table`(화면 없음 · 인쇄 미리보기도 씀 · 302-3) * ========================================================================== */ import "./B08_Quantity_UI_Milestone.css"; @@ -21,7 +22,8 @@ import { attachHoverInfo } from "@ui/ui_template_hover_info"; import type { Row } from "../B09_Estimation/B09_Estimation_Calc_Base"; import { openHaulModal } from "../B09_Estimation/B09_Estimation_UI_Haul_Modal"; import { openPriceLink } from "../B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link"; -import { computeMilestone, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; +import type { MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; +import { DEST_TEXT, milestoneColumns, milestoneTable } from "./B08_Quantity_Milestone_Table"; import { createDiagram } from "./B08_Quantity_UI_Milestone_Diagram"; import { liveMilestone, @@ -31,7 +33,6 @@ import { siteInfo, } from "./B08_Quantity_UI_Milestone_State"; import { - DEST_TEXT, editRoute, pickAtCell, routeView, @@ -39,9 +40,6 @@ import { type MilestoneView, } from "./B08_Quantity_UI_Milestone_Route"; -const KM = { kind: "number", decimals: 3, trim: true, width: 72 } as const; -const ROAD = ["도로 구분 (km)"]; -const SUM = ["계 (km)"]; /** 단가연결 램프 칸 class(`B08_Quantity_UI_Milestone.css`) */ const LAMP = "b08-milestone__lamp"; @@ -55,32 +53,6 @@ export function refText(ref: Record | null | undefined): string return `단가연결 — ${what.trim()}`; } -/** 표 열(244-13) — 2차포장 열 = 기준점 있을 때만 · 기준점 없음 = 포장(2차 + 1차 + 포장) · 비포장 · 합계 · - * 기준점 있음 = 거침 안 한 거래처 「포장」 값이 있으면 포장 열도 */ -export function milestoneColumns(withBase: boolean, anyPaved: boolean): DataColumn[] { - const roads: DataColumn[] = withBase - ? [ - { key: "s2", label: "2차포장", group: ROAD, ...KM }, - { key: "s1", label: "1차포장", group: ROAD, ...KM }, - ...(anyPaved ? [{ key: "pv", label: "포장", group: ROAD, ...KM }] : []), - { key: "un", label: "비포장", group: ROAD, ...KM }, - { key: "paved", label: "포장", group: SUM, ...KM }, - { key: "unpaved", label: "비포장", group: SUM, ...KM }, - ] - : [ - { key: "paved", label: "포장", group: ROAD, ...KM }, - { key: "un", label: "비포장", group: ROAD, ...KM }, - ]; - return [ - { key: "item", label: "항목", width: 134 }, - { key: "company", label: "거래처 이름", width: 120 }, - { key: "dest", label: "목적지", width: 72 }, - ...roads, - { key: "total", label: "합계", group: SUM, ...KM, width: 108 }, - { key: "note", label: "비고", width: 160 }, - ]; -} - /** 새 경로(거래처) — 캐시에 넣고 id 를 돌려줌 */ export function addRoute(projectId: string, init: Partial = {}): string { const data = liveMilestone(projectId); @@ -180,17 +152,11 @@ function tableView(projectId: string): MilestoneView { }); const refresh = (): void => { const data = liveMilestone(projectId); - const rows = computeMilestone(data, siteInfo()?.mid_default_m ?? null); + const { options, rows, mode: next } = milestoneTable(data, siteInfo()?.mid_default_m ?? null); // 기준점 생김 · 없앰 = 열 바뀜 → 표 새로 - const next = `${data.bases.length > 0}:${rows.some((r) => r.pv > 0)}`; if (next !== mode) { mode = next; - const fresh = makeTable( - milestoneColumns( - data.bases.length > 0, - rows.some((r) => r.pv > 0), - ), - ); + const fresh = makeTable(options.columns); table.root.replaceWith(fresh.root); table = fresh; } @@ -201,7 +167,7 @@ function tableView(projectId: string): MilestoneView { L2: unpaved, })); none.hidden = rows.length > 0; - table.setRows(rows.map((r) => ({ ...r, dest: `${DEST_TEXT[r.dest]} ▾` }))); + table.setRows(options.rows.map((r) => ({ ...r, dest: `${r.dest} ▾` }))); diagram.refresh(); }; refresh(); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts index f7ea25721..cc02d0d09 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts @@ -15,8 +15,9 @@ import { type MapContextMenuItem, } from "@ui/ui_template_context_menu"; import { createButton, el, showToast } from "@ui/ui_template_elements"; -import type { Dest, MilestoneData, MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; +import type { MilestoneData, MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; import { chainOf, ownerOf } from "./B08_Quantity_Milestone_Layout"; +import { DEST_TEXT } from "./B08_Quantity_Milestone_Table"; import { editMap, type MapPin } from "./B08_Quantity_UI_Milestone_KakaoMap"; import { reverseAddress } from "./B08_Quantity_UI_Milestone_Place"; import { @@ -54,8 +55,6 @@ export function pickAtCell(items: MapContextMenuItem[]): void { menu.open(box.left, box.bottom, items); } -export const DEST_TEXT: Record = { start: "시점", mid: "중점" }; - /** 그 경로를 고쳐 캐시에 */ export function editRoute( projectId: string, diff --git a/B08_Quantity/B08_Quantity_UI_Summary.ts b/B08_Quantity/B08_Quantity_UI_Summary.ts index 4d74c1927..34b70dce8 100644 --- a/B08_Quantity/B08_Quantity_UI_Summary.ts +++ b/B08_Quantity/B08_Quantity_UI_Summary.ts @@ -16,50 +16,28 @@ * 줄을 누르면 그 측점의 구조물도 모달(PLAN 95-3 · `B08_Quantity_UI_Drawing` 은 누를 때 `import()`) * 미연결 시설 = 모달 맨 위 「조합 연결」 → 바로 저장 → 탭 몸통을 새로 그림(154-2) * 커플링 열 = 좌측 패널 1본 길이로 바로 다시 셈(관마다 길이 `pipes` · `recountCouplings` · PLAN 122) + * 자료 읽기 · 표 짜기 = `B08_Quantity_Summary_Table`(화면 없음 · 인쇄 미리보기도 씀 · 302-3) * ========================================================================== */ -import { API_BASE_URL } from "@config/config_frontend"; import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { createCard, showToast } from "@ui/ui_template_elements"; -import { fetchStructures } from "../B05_Profile/B05_Profile_Api_Structures"; -import { fetchLatestRoute, readRouteLatestCache } from "../B05_Profile/B05_Profile_Api_Fetch"; -import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; -import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table"; +import { createDataTable } from "@ui/ui_template_data_table"; import { attachHoverInfo } from "@ui/ui_template_hover_info"; -import { pipeStructureId } from "@ui/ui_template_item_picker_project"; -import { stationLabel } from "../common_util/common_util_station"; -import { summaryTooltip, type SummaryItem } from "../common_util/common_util_summary_item"; +import { combosAt, unlinkedAt } from "./B08_Quantity_Summary_Data"; import { - combosAt, - comboId, - linkedOnly, - pipeOwnerId, - summaryColumns, - summaryStations, - type ComboColumn, - type ComboLabel, - type LinkedSnapshot, - type PlacedItem, - type UnlinkedFacility, - unassignedText, - unlinkedAt, - widthOf, -} from "./B08_Quantity_Summary_Data"; -import { recountCouplings, type PipeLength } from "./B08_Quantity_Options_Calc"; + LAMP_CLASS, + loadSummary, + summaryTable, + type SummaryView, +} from "./B08_Quantity_Summary_Table"; import type { SheetsRequest } from "./B08_Quantity_UI_Drawing"; import { footNote } from "./B08_Quantity_UI_Foot"; -import { liveOnePipe, onOptionsChange } from "./B08_Quantity_UI_Options"; +import { onOptionsChange } from "./B08_Quantity_UI_Options"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } -/** 측점 간격 — 노선 설정값 · 못 읽으면 20m. */ -export async function stationInterval(projectId: string): Promise { - const latest = readRouteLatestCache(projectId) ?? (await fetchLatestRoute(projectId)); - return latest.route_params?.station_interval_m || 20; -} - function note(text: string): HTMLElement { const node = document.createElement("p"); node.className = "ui-general-block__empty"; @@ -67,128 +45,14 @@ function note(text: string): HTMLElement { return node; } -/** 서버 배관 집계(PLAN 98-1) — 열 머리 `group`(구조물 · 형식 · 규격) + `label`(단위) · 열마다 조합 id. */ -interface PipeColumn { - id: string; - group: string[]; - label: string; - combos: string[]; -} -interface PipeSummary { - columns: PipeColumn[]; - rows: Array<{ chainage_m: number; values: Record }>; - total: Record; - notes: string[]; - /** 서버가 펼친 종류(`type_id` · 관 지점은 `facility`) — 조합 열에서 뺌 */ - types: string[]; - one_length_m: number | null; - pipes: PipeLength[]; - /** 연결된 사본 `{구조물 id: 사본}`(143-1) */ - linked: Record; - /** 미연결 시설(154-1) */ - unlinked: UnlinkedFacility[]; - /** 모든 시설 칸(모달 「조합 연결」 · 156-1) */ - facilities: UnlinkedFacility[]; -} - -async function fetchPipeSummary(projectId: string): Promise { - const response = await fetch(`${API_BASE_URL}/projects/${projectId}/quantity/summary`, { - credentials: "include", - }); - if (!response.ok) throw new Error(`${response.status} ${await response.text()}`); - return response.json(); -} - -const PIPE_TOP = "관 공"; -/** 미연결 램프 칸 class(`B08_Quantity_UI_Page.css` · 198-1) · 램프 자리 폭(12px 글 기준) */ -const LAMP_CLASS = "b08-summary__lamp"; -/** 모두 연결 = 같은 램프 초록(247-1) */ -const LINKED_CLASS = `${LAMP_CLASS} is-linked`; -const LAMP_W = 14; -const stationKey = (station: number): number => Math.round(station * 100); -/** 열 소수 자리 — 칸 · 계 값이 쓰는 만큼(최대 2). */ -const placesOf = (values: number[]): number => - Math.max(0, ...values.map((v) => (String(Math.round(v * 100) / 100).split(".")[1] ?? "").length)); - -/** 배관 열 → 표 열 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(사본 경로 · 조합 잇기 98). */ -function pipeColumns(summary: PipeSummary, comboPath: (id: string) => string | null): DataColumn[] { - const top = - summary.columns - .filter((column) => column.group[0] === PIPE_TOP) - .flatMap((column) => column.combos) - .map(comboPath) - .find((path) => !!path) ?? PIPE_TOP; - return summary.columns.map((column) => { - const group = column.group[0] === PIPE_TOP ? [top, ...column.group.slice(1)] : column.group; - const values = [ - ...summary.rows.map((row) => row.values[column.id]), - summary.total[column.id], - ].filter((value): value is number => typeof value === "number"); - const decimals = placesOf(values); - const texts = values.map((value) => - value.toLocaleString("en-US", { - minimumFractionDigits: decimals, - maximumFractionDigits: decimals, - }), - ); - return { - key: column.id, - label: column.label, - group, - kind: "number", - decimals, - title: [...group, column.label].join(" › "), - width: widthOf(column.label, ...texts), - }; - }); -} - function buildTable( - stations: number[], - columns: ComboColumn[], - pipe: PipeSummary | null, - comboPath: (id: string) => string | null, - interval: number, - unlinked: readonly UnlinkedFacility[], + view: SummaryView, onRowClick: (station: number, label: string) => void, ): HTMLElement { - const values = new Map((pipe?.rows ?? []).map((row) => [stationKey(row.chainage_m), row.values])); - const total: DataRow | null = pipe?.columns.length - ? { station: L("B08_Quantity_Summary_Total"), ...pipe.total } - : null; - const labels = stations.map((station) => stationLabel(station, interval)); - const stationHead = L("B08_Quantity_Summary_Station"); - // 198-1 — 미연결 시설 측점 = 측점 칸 왼쪽 노랑 램프(값 빈칸 · 누르면 바로 연결 · 154) · 호버 = 빈 조합 칸(160-2) - const missing = stations.map((station) => - unassignedText(unlinkedAt(unlinked, station), L("B08_Quantity_Link_Body")), - ); - // 247-1 — 시설이 있고 빈 칸 없음 = 초록 램프 - const linked = stations.map((station) => unlinkedAt(pipe?.facilities ?? [], station).length > 0); + const { options, labels, missing } = summaryTable(view); const table = createDataTable({ - columns: [ - { key: "station", label: stationHead, width: widthOf(stationHead, ...labels) + LAMP_W }, - ...columns.map((column) => ({ - key: column.id, - label: column.name, - group: [column.group || L("B08_Quantity_Summary_MissingGroup")], - title: summaryTooltip(column.item), - className: column.missing ? "is-missing" : undefined, - width: widthOf(column.name), - })), - ...(pipe ? pipeColumns(pipe, comboPath) : []), - ], - // 조합 열 칸은 아직 비움 — 사용자가 채울 자리라 「-」 대신 빈칸 · 배관 칸 = 서버 값 - rows: stations.map((station, i) => ({ - station: labels[i], - ...values.get(stationKey(station)), - })), - total, - onRowClick: (i) => onRowClick(stations[i], labels[i]), - cellClass: (i, key) => - key !== "station" ? null : missing[i] ? LAMP_CLASS : linked[i] ? LINKED_CLASS : null, - frozen: 1, - empty: "", - caption: L("B08_Quantity_Tab_StructureSummary"), + ...options, + onRowClick: (i) => onRowClick(view.stations[i], labels[i]), fillViewport: true, }); table.root.dataset.b08Summary = "table"; @@ -220,59 +84,17 @@ async function openSheets(request: SheetsRequest): Promise { export async function renderStructureSummary(host: HTMLElement, projectId: string): Promise { host.replaceChildren(note(L("B08_Quantity_Summary_Loading"))); try { - const [structures, pipes, interval, pipe] = await Promise.all([ - fetchStructures(projectId), - // 배수 분석 전이면 관이 없음 — 구조물만으로 그림 - fetchDetailPipePoints(projectId).then( - (response) => response.pipe_points, - () => [], - ), - stationInterval(projectId).catch(() => 20), - // 배관 집계 실패 = 까닭 한 줄 · 나머지 표는 그대로 - fetchPipeSummary(projectId).catch((error: unknown) => String(error)), - ]); - const saved = typeof pipe === "string" ? null : pipe; - // 조합 이름 = 사본 manifest 경로(층 목록을 안 읽음 · 143-1) - const linked = saved?.linked ?? {}; - const paths = new Map( - Object.values(linked).map((copy) => [comboId(copy.항목), copy.경로] as const), - ); - const describe = (item: SummaryItem): ComboLabel | null => { - const [group = "", ...rest] = paths.get(comboId(item)) ?? []; - return group ? { group, name: rest.join(" ") || group } : null; - }; - const comboPath = (id: string): string | null => paths.get(id)?.join(" ") || null; - // 관 지점 id = 서버가 낸 id 먼저(응답 측점은 둘째 자리 · id 는 셋째 자리 · 167-1) - const ids = [...Object.keys(linked), ...(saved?.facilities ?? []).map((f) => f.id)]; - const placed = linkedOnly( - pipes.map((point) => ({ - ...point, - structure_id: pipeOwnerId(point.chainage_m, ids) ?? pipeStructureId(point.chainage_m), - })), - linked, - ); - const mine = linkedOnly(structures.structures, linked); - const things: PlacedItem[] = [...mine, ...placed]; - const unlinked = saved?.unlinked ?? []; - const stations = summaryStations([...things, ...unlinked]); - // 횡단배수는 서버 집계 열(98 · 120) — 조합 열은 다른 구조물 · 시설만(모달은 그대로 전부) - const served = new Set(saved?.types ?? []); - const others = [ - ...mine.filter((item) => !served.has(item.type_id)), - ...placed.filter((point) => !served.has(point.facility ?? "pipe")), - ]; - const columns = summaryColumns(others, describe); - const live = (): PipeSummary | null => - saved && recountCouplings(saved, liveOnePipe(projectId, saved.one_length_m)); - const summary = live(); + const view = await loadSummary(projectId); + const { stations, things, linked, describe, saved } = view; + const summary = view.live(); const parts: HTMLElement[] = []; - if (typeof pipe === "string") parts.push(note(`${L("B08_Quantity_Summary_Failed")} — ${pipe}`)); + if (view.error) parts.push(note(`${L("B08_Quantity_Summary_Failed")} — ${view.error}`)); if (summary?.notes.length) parts.push(footNote(summary.notes.join(" · "))); // 236-1 한 줄 - if (!columns.length && !summary?.columns.length) + if (!view.columns.length && !summary?.columns.length) parts.push(note(L("B08_Quantity_Summary_NoCombo"))); const draw = (): HTMLElement => stations.length - ? buildTable(stations, columns, live(), comboPath, interval, unlinked, (station, label) => + ? buildTable(view, (station, label) => openSheets({ projectId, station: label, diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 10e53f93e..0da23dee3 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -29,6 +29,9 @@ from __future__ import annotations import asyncio +import copy +import importlib +import logging from pathlib import Path from typing import Any from uuid import UUID @@ -341,3 +344,28 @@ async def library_inputs( # 166-4 견적서 파일 길 — 같은 머리 · 같은 권한 router.include_router(quotes_router) + + +# 302 초안 길(draft/*_Router.py 의 router · 폴더째 지우면 길도 없음) — 같은 머리 · 같은 권한 +def include_drafts(target: APIRouter, folder: Path, package: str) -> list[str]: + """folder 의 *_Router.py 마다 router 를 끼움 → 끼운 파일 이름(못 읽은 초안은 건너뜀). + 초안이 같은 머리(/api/projects)를 스스로 달았으면 떼고 끼움 — 머리가 두 번 붙지 않게.""" + done = [] + for path in sorted(folder.glob("*_Router.py")): + try: + draft = importlib.import_module(f"{package}.{path.stem}").router + if target.prefix and draft.prefix.startswith(target.prefix): + # 길은 만들 때 머리가 박힘 — 사본 길에서 떼어 냄(원본은 앱에 바로 붙일 때 그대로) + shim, cut = copy.copy(draft), len(target.prefix) + shim.routes = [copy.copy(route) for route in draft.routes] + for route in shim.routes: + route.path = route.path[cut:] + draft = shim + target.include_router(draft) + done.append(path.stem) + except (ImportError, AttributeError) as error: # 한 초안이 막혀도 B09 는 뜸 + logging.getLogger(__name__).warning("초안 길 %s 못 읽음 — %s", path.name, error) + return done + + +include_drafts(router, Path(__file__).parent / "draft", "B09_Estimation.draft") diff --git a/B09_Estimation/B09_Estimation_UI_Builders.ts b/B09_Estimation/B09_Estimation_UI_Builders.ts new file mode 100644 index 000000000..fb846bcde --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Builders.ts @@ -0,0 +1,85 @@ +/* ============================================================================= + * B09_Estimation_UI_Builders.ts + * B09 탭 표 짜기(PLAN 303-2) — 탭 → 표(`TabTable`) · 표 → 공용 표 옵션(DOM · 누름 없이). + * + * 화면(Shell) · 팝업 창(303-2) · 인쇄(302-4)가 같은 표 짜기를 씀 — 보는 값 = 찍는 값 + * 초안 탭 = `DRAFT.builders`(draft 폴더가 넣음 · 없으면 빈 채로) + * ========================================================================== */ + +import type { DataTableOptions } from "@ui/ui_template_data_table"; +import { buildDaegaBlocks, buildDaegaList } from "./B09_Estimation_UI_Tabs_Labor"; +import { + DRAFT, + TABS, + type TabBuild, + type TabContext, + type TabId, + type TabTable, +} from "./B09_Estimation_UI_Tabs"; +import { + buildBasis, + buildCompare, + buildExpense, + buildLabor, + buildMachine, + buildMachineBlocks, + buildMaterial, +} from "./B09_Estimation_UI_Tabs_Base"; +import { buildBill } from "./B09_Estimation_UI_Tabs_Bill"; +import type { BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit"; +import { + buildSumExpense, + buildSumLabor, + buildSumMachine, + buildSumMaterial, +} from "./B09_Estimation_UI_Tabs_Summary"; +import { buildCost, buildTotal } from "./B09_Estimation_UI_Tabs_Total"; +import { buildSg, buildSgList } from "./B09_Estimation_UI_Tabs_Sangeun"; + +const BUILDERS: Partial> = { + dg_list: buildDaegaList, + dg: buildDaegaBlocks, + bill: buildBill, + basis: buildBasis, + machine: buildMachine, + machine_blocks: buildMachineBlocks, + material: buildMaterial, + labor: buildLabor, + expense: buildExpense, + compare: buildCompare, + cost: buildCost, + total: buildTotal, + sum_material: buildSumMaterial, + sum_labor: buildSumLabor, + sum_expense: buildSumExpense, + sum_machine: buildSumMachine, + sg_list: buildSgList, + sg: buildSg, +}; + +/** 탭 짜기 — 없는 탭 = null(「다음 단계에서 채움」) · 글 = 안내만 */ +export function tabTableOf(tab: TabId, ctx: TabContext): TabTable | string | null { + const build = BUILDERS[tab] ?? DRAFT.builders[tab]; + return build ? build(ctx) : null; +} + +/** 표 → 공용 표 옵션(화면과 같은 값 · 꼴) — 누름 · 메뉴 · 풍선은 그리는 쪽 몫 */ +export function tableOptionsOf(spec: TabTable, tab?: TabId): DataTableOptions { + return { + columns: spec.columns, + rows: spec.rows, + total: spec.total ?? null, + frozen: spec.frozen ?? 0, + empty: "", + caption: TABS.find(([id]) => id === tab)?.[1], + editable: spec.editable, + // 243-1 고칠 수 있는 칸 = 모든 표 더블클릭 · Enter · F2 (설계내역서 키 = 레벨 키 몫) + editOnDouble: spec.editOnDouble ?? true, + keyEdit: !(spec as BillTable).menu, + onEdit: spec.onEdit, + rowClass: spec.rowClass, + cellClass: spec.cellClass, + level: spec.level, + mergeSame: spec.mergeSame, + }; +} diff --git a/B09_Estimation/B09_Estimation_UI_Dock.ts b/B09_Estimation/B09_Estimation_UI_Dock.ts new file mode 100644 index 000000000..c71514586 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Dock.ts @@ -0,0 +1,106 @@ +/* ============================================================================= + * B09_Estimation_UI_Dock.ts + * B09 좌측 하단 고정 줄(PLAN 303-1 · 304-1) — 위에서부터 + * ① [박스보기|호표보기] 묶음 탭 셋 보기(304-1 · 한 값) + * ② [하이퍼링크|팝업] 행 누름(303 · 기본 팝업) + * ③ `DRAFT.actions` 단추(초안 · 비면 줄 없음) + * ④ [?][저장][내역 초기화](도움말 줄 `data-help-row`) + * + * 스위치 값 = pref global(계정 공용 · [저장] 필요 없음) · 바닥 고정 = 공용 `.ui-sidebar-actions` 한 벌 + * ========================================================================== */ + +import { createButton } from "@ui/ui_template_elements"; +import { readState, writeState } from "../A00_Common/b_page_state"; +import { DRAFT } from "./B09_Estimation_UI_Tabs"; +import { + chooseGroupView, + groupView, + viewLabel, + type GroupView, +} from "./B09_Estimation_UI_Tabs_Groups"; + +export type LinkMode = "popup" | "link"; + +const LINK_LABEL: Record = { link: "하이퍼링크", popup: "팝업" }; + +/** 행 누름 길 — 모르는 값 · 못 읽음 = 팝업 */ +export function linkMode(): LinkMode { + try { + return readState("b09-link-mode") === "link" ? "link" : "popup"; + } catch { + return "popup"; + } +} + +function setLinkMode(mode: LinkMode): void { + try { + writeState("b09-link-mode", mode); + } catch { + // 기억 못 하면 기본(팝업)으로 돌아감 + } +} + +/** 단추 둘 스위치 — 켜진 쪽 filled · aria-pressed */ +function switchRow( + name: string, + values: T[], + label: (value: T) => string, + current: () => T, + pick: (value: T) => void, +): HTMLElement { + const row = document.createElement("div"); + row.className = "ui-btn-group b09-dock__row"; + row.dataset.b09Switch = name; + const buttons = values.map((value) => { + const button = createButton({ + label: label(value), + variant: "ghost", + onClick: () => { + if (value === current()) return; + pick(value); + sync(); + }, + }); + button.dataset.value = value; + return button; + }); + const sync = (): void => + buttons.forEach((button, k) => { + const on = values[k] === current(); + button.classList.toggle("ui-btn--filled", on); + button.classList.toggle("ui-btn--ghost", !on); + button.setAttribute("aria-pressed", String(on)); + }); + sync(); + row.append(...buttons); + return row; +} + +/** 하단 고정 줄 — onView = 보기 바뀜(묶음 탭이면 다시 그림) */ +export function createDock(buttons: HTMLElement[], onView: () => void): HTMLElement { + const views: GroupView[] = ["cards", "table"]; + const view = switchRow("view", views, viewLabel, groupView, (value) => { + chooseGroupView(value); + onView(); + }); + const link = switchRow( + "link", + ["link", "popup"], + (mode) => LINK_LABEL[mode], + linkMode, + setLinkMode, + ); + const extra = DRAFT.actions.map((make) => make()); + const draft = document.createElement("div"); + draft.className = "b09-dock__row"; + draft.dataset.b09DraftActions = ""; + draft.append(...extra); + const main = document.createElement("div"); + main.className = "b09-dock__row"; + main.dataset.helpRow = ""; + main.append(...buttons); + const dock = document.createElement("div"); + dock.className = "ui-sidebar-actions b09-dock"; + dock.append(view, link, ...(extra.length ? [draft] : []), main); + return dock; +} diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index 81137921b..43773307e 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -248,3 +248,57 @@ .b09-estimation [data-b09-table="bill"] tbody tr.is-drag-src td { opacity: 0.45; } + +/* 303-1 · 304-1 좌측 하단 고정 줄 — 줄 여럿 세로(보기 · 행 누름 스위치 · 초안 단추 · [?][저장][내역 초기화]) */ +.b09-dock.ui-sidebar-actions { + flex-direction: column; +} + +.b09-dock.ui-sidebar-actions > * { + flex: none; +} + +.b09-dock .b09-dock__row { + display: flex; + gap: var(--btn-group-gap); +} + +.b09-dock .b09-dock__row > .ui-btn:not(.ui-help-btn) { + flex: 1 1 0; + min-width: auto; + padding-inline: var(--spacing-4); + white-space: nowrap; +} + +/* 303-2 행 고름 팝업 — 누른 줄 곁 표 조각 창(겹쳐 쌓임 · 창 안 b09 표 꼴 그대로 · 빨강 테두리) */ +.b09-estimation.b09-popup { + position: fixed; + z-index: var(--z-modal); + gap: var(--spacing-4); + width: max-content; + max-width: 90vw; + max-height: 80vh; + padding: var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface-raised); + box-shadow: var(--shadow-lg); +} + +.b09-popup__head { + display: flex; + flex: none; + align-items: center; + gap: var(--spacing-4); +} + +.b09-popup__title { + margin-right: auto; + font-weight: var(--font-weight-bold); +} + +.b09-popup .ui-data-table { + flex: 1 1 auto; + min-height: 0; + max-height: none; +} diff --git a/B09_Estimation/B09_Estimation_UI_Popup.ts b/B09_Estimation/B09_Estimation_UI_Popup.ts new file mode 100644 index 000000000..62c9ee360 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Popup.ts @@ -0,0 +1,175 @@ +/* ============================================================================= + * B09_Estimation_UI_Popup.ts + * B09 행 고름 팝업(PLAN 303-2) — 링크 있는 줄 · 박스 카드 누름 = 대상 탭 표 조각 창(옮기지 않음). + * + * 창 = 열 제목(머리 여러 줄 그대로) + 강조 범위 줄 전체 · 표 짜기 = `…_UI_Builders`(화면과 같은 옵션) + * 강조 범위 = 설계내역서 그 줄 + 자손 · 묶음 탭 호표 구간 · 그 밖 한 줄(하이퍼링크 강조와 같음) + * 자리 = 누른 줄 아래(모자라면 위 · 화면 안) · 너비 = 표 너비(최대 화면 90%) · 넘치면 창 안 스크롤 + * 창 안 링크 줄 누름 = 이어진 창(겹쳐 위에 · 그 줄 곁) · Esc · × = 맨 위 하나 · 탭 바꿈 · 주소 바뀜 = 모두 + * 머리 [탭으로] = 하이퍼링크처럼 옮김(모두 닫음) · 창 안 = 보기 전용(고칠 칸 · 메뉴 없음) + * ========================================================================== */ + +import { createDataTable, type DataTableOptions } from "@ui/ui_template_data_table"; +import { createButton } from "@ui/ui_template_elements"; +import { placeHoverInfo } from "@ui/ui_template_hover_info"; +import { tableOptionsOf } from "./B09_Estimation_UI_Builders"; +import { rowIndex, tabName, type Link, type TabId, type TabTable } from "./B09_Estimation_UI_Tabs"; +import type { BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit"; +import { subtreeEnd } from "./B09_Estimation_UI_Tabs_Bill_Level"; +import { isGroupTab, pickClass, pickRange } from "./B09_Estimation_UI_Tabs_Groups"; + +const EDGE = 8; + +/** 강조 범위 [첫 줄, 끝 줄] — 대상 줄 없음 = null */ +export function popupRange(tab: TabId, spec: TabTable, key: string): [number, number] | null { + if (isGroupTab(tab)) return pickRange(spec.rows, key); + const at = rowIndex(spec.rows, key); + if (at < 0) return null; + return (spec as BillTable).menu ? [at, subtreeEnd(spec.rows, at)] : [at, at]; +} + +/** 창 표 옵션 — 범위 줄만 · 줄 번호 곁값은 원래 줄 번호로 · 빨강 테두리 · 합계 · 고칠 칸 없음 */ +export function popupOptions( + spec: TabTable, + tab: TabId, + range: [number, number], +): DataTableOptions { + const [from, to] = range; + const at = (i: number): number => i + from; + const level = spec.level; + return { + ...tableOptionsOf(spec, tab), + rows: spec.rows.slice(from, to + 1), + total: null, + editable: undefined, + onEdit: undefined, + rowClass: (i) => + [spec.rowClass?.(at(i)), pickClass(range, at(i))].filter(Boolean).join(" ") || null, + cellClass: spec.cellClass && ((i, key) => spec.cellClass?.(at(i), key)), + level: level && { + ...level, + of: (i) => level.of(at(i)), + group: level.group && ((i) => level.group?.(at(i)) ?? false), + }, + }; +} + +/** 창 자리 — 누른 줄 아래(모자라면 위) · 화면 밖으로 안 나감 */ +export function popupPlace( + anchor: { left: number; top: number; right: number; bottom: number }, + size: { width: number; height: number }, + view: { width: number; height: number }, +): { x: number; y: number } { + const { x, y } = placeHoverInfo(anchor, size, view); + return { x, y: Math.max(EDGE, Math.min(y, view.height - EDGE - size.height)) }; +} + +/** 열린 창(아래 → 위) */ +const stack: HTMLElement[] = []; +let listening = false; + +/** from 번째 창부터 위로 모두 닫음(0 = 모두) */ +export function closePopups(from = 0): void { + for (const box of stack.splice(from)) box.remove(); +} + +export const popupCount = (): number => stack.length; + +function listen(): void { + if (listening) return; + listening = true; + // Esc = 맨 위 하나(뒤 화면의 Esc 풂보다 먼저 · 창 있을 때만 가로챔) + window.addEventListener( + "keydown", + (event) => { + if (event.key !== "Escape" || !stack.length) return; + event.preventDefault(); + event.stopPropagation(); + closePopups(stack.length - 1); + }, + true, + ); + window.addEventListener("hashchange", () => closePopups()); +} + +export interface PopupOpen { + link: Link; + /** 누른 줄 · 카드 줄(자리 기준) — 없으면 화면 가운데 위 */ + anchor: Element | null; + /** 대상 탭 표(화면과 같은 표 짜기) — 없으면 null */ + specOf: (tab: TabId) => TabTable | null; + /** 하이퍼링크처럼 옮김(대상 줄 없음 · [탭으로]) */ + go: (link: Link) => void; + /** 참 = 창 안 누름(이어진 창) · 거짓 = 화면 누름(있던 창 모두 닫고 새로) */ + chain?: boolean; +} + +export function openRowPopup(o: PopupOpen): void { + if (!o.chain) closePopups(); + const { tab, key } = o.link; + const spec = o.specOf(tab); + const range = spec && popupRange(tab, spec, key); + if (!spec || !range) return o.go(o.link); // 보일 줄 없음 = 옮김 + listen(); + const level = stack.length; + const box = document.createElement("div"); + box.className = "b09-estimation b09-popup"; + box.setAttribute("role", "dialog"); + box.setAttribute("aria-label", tabName(tab)); + box.dataset.b09Popup = tab; + const title = document.createElement("span"); + title.className = "b09-popup__title"; + title.textContent = tabName(tab); + const goButton = createButton({ + label: "탭으로", + variant: "ghost", + size: "sm", + onClick: () => { + closePopups(); + o.go(o.link); + }, + }); + goButton.dataset.b09PopupGo = ""; + const closeButton = createButton({ + label: "×", + variant: "ghost", + size: "sm", + onClick: () => closePopups(level), + }); + closeButton.dataset.b09PopupClose = ""; + closeButton.setAttribute("aria-label", "닫기"); + const head = document.createElement("div"); + head.className = "b09-popup__head"; + head.append(title, goButton, closeButton); + const from = range[0]; + const table = createDataTable({ + ...popupOptions(spec, tab, range), + // 창 안 링크 줄 = 이어진 창(고칠 칸 · 「단가연결」 칸은 화면처럼 안 엶) + onCellClick: (i, cellKey) => { + if (spec.editable?.(i + from, cellKey)) return; + const next = spec.link?.(i + from, cellKey); + if (next) + openRowPopup({ + ...o, + link: next, + anchor: table.root.querySelector(`tr[data-i="${i}"]`), + chain: true, + }); + }, + }); + table.root.dataset.b09Table = tab; + box.append(head, table.root); + document.body.append(box); + stack.push(box); + const view = { width: window.innerWidth, height: window.innerHeight }; + const size = box.getBoundingClientRect(); + const anchor = o.anchor?.getBoundingClientRect() ?? { + left: (view.width - size.width) / 2, + right: (view.width + size.width) / 2, + top: view.height / 4, + bottom: view.height / 4, + }; + const { x, y } = popupPlace(anchor, size, view); + box.style.left = `${x}px`; + box.style.top = `${y}px`; +} diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 1b331e139..81dc5157f 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -32,36 +32,19 @@ import { computeLabor, daegaPrice } from "./B09_Estimation_Calc_Labor"; import { computeTotal } from "./B09_Estimation_Calc_Total"; import { createTabBars } from "./B09_Estimation_UI_TabBar"; import { createCheckPanel } from "./B09_Estimation_UI_Check"; -import { buildDaegaBlocks, buildDaegaList } from "./B09_Estimation_UI_Tabs_Labor"; +import { tableOptionsOf, tabTableOf } from "./B09_Estimation_UI_Builders"; +import { createDock, linkMode } from "./B09_Estimation_UI_Dock"; +import { closePopups, openRowPopup } from "./B09_Estimation_UI_Popup"; import { DRAFT, rowIndex, TABS, type Link, - type TabBuild, type TabContext, type TabId, } from "./B09_Estimation_UI_Tabs"; -import { - buildBasis, - buildCompare, - buildExpense, - buildLabor, - buildMachine, - buildMachineBlocks, - buildMaterial, -} from "./B09_Estimation_UI_Tabs_Base"; -import { buildBill } from "./B09_Estimation_UI_Tabs_Bill"; import { attachRowMenu, openMenuAt, type BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit"; import { jumpRange, jumpTo, levelBar, pickedClass } from "./B09_Estimation_UI_Tabs_Bill_Level"; -import { - buildSumExpense, - buildSumLabor, - buildSumMachine, - buildSumMaterial, -} 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, @@ -79,27 +62,6 @@ function L(key: keyof typeof ui_locales): string { const LATER: Record = {}; -const BUILDERS: Partial> = { - dg_list: buildDaegaList, - dg: buildDaegaBlocks, - bill: buildBill, - basis: buildBasis, - machine: buildMachine, - machine_blocks: buildMachineBlocks, - material: buildMaterial, - labor: buildLabor, - expense: buildExpense, - compare: buildCompare, - cost: buildCost, - total: buildTotal, - sum_material: buildSumMaterial, - sum_labor: buildSumLabor, - sum_expense: buildSumExpense, - sum_machine: buildSumMachine, - sg_list: buildSgList, - sg: buildSg, -}; - async function request(path: string, init: RequestInit = {}): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include", @@ -194,41 +156,40 @@ export function renderB09Estimation(root: HTMLElement): void { /** 탭 하나 그림 · focus = 이동해 온 줄 키 */ const show = (tab: TabId, focus?: string): void => { current = tab; + closePopups(); // 303-2 탭 바꿈 · 다시 그림 = 창 모두 닫음 bars.draw(tab); bars.setTools([]); // 보기 단추 · 열 수는 묶음 탭만 다시 채움(202-1) if (!projectId) return void panel.replaceChildren(note("프로젝트를 먼저 고르세요.")); if (!payload) return void panel.replaceChildren(note("원가 자료를 불러오는 중…")); - const build = BUILDERS[tab] ?? DRAFT.builders[tab]; - if (!build) return void panel.replaceChildren(note(LATER[tab] ?? "다음 단계에서 채움")); const ctx: TabContext = { sheets, payload, user, setUser }; - const spec = build(ctx); + const spec = tabTableOf(tab, ctx); + if (!spec) return void panel.replaceChildren(note(LATER[tab] ?? "다음 단계에서 채움")); if (typeof spec === "string") return void panel.replaceChildren(note(spec)); - const goto = (link: Link | null): void => { - if (link) show(link.tab, link.key); + // 303 행 누름 = 팝업(기본 · 누른 줄 곁 표 조각) · 하이퍼링크(그 탭 그 줄로) + const goto = (link: Link | null, anchor?: Element | null): void => { + if (!link) return; + if (linkMode() === "link") return show(link.tab, link.key); + openRowPopup({ + link, + anchor: anchor ?? null, + specOf: (t) => { + const made = tabTableOf(t, ctx); + return made && typeof made === "object" ? made : null; + }, + go: (to) => show(to.tab, to.key), + }); }; // extra = 줄 class 곁값(181-2 표 보기 호표 구간) const makeTable = (extra?: (index: number) => string | null): DataTable => { const made = createDataTable({ - columns: spec.columns, - rows: spec.rows, - total: spec.total ?? null, - frozen: spec.frozen ?? 0, - empty: "", - caption: TABS.find(([id]) => id === tab)?.[1], - editable: spec.editable, - // 243-1 고칠 수 있는 칸 = 모든 표 더블클릭 · Enter · F2 (설계내역서 키 = 레벨 키 몫) - editOnDouble: spec.editOnDouble ?? true, - keyEdit: !(spec as BillTable).menu, - onEdit: spec.onEdit, + ...tableOptionsOf(spec, tab), rowClass: extra ? (i) => [spec.rowClass?.(i), extra(i)].filter(Boolean).join(" ") || null : spec.rowClass, - cellClass: spec.cellClass, - level: spec.level, - mergeSame: spec.mergeSame, onCellClick: (i, key) => { if (spec.onCell) spec.onCell(i, key); - if (spec.link && !spec.editable?.(i, key)) goto(spec.link(i, key)); + if (spec.link && !spec.editable?.(i, key)) + goto(spec.link(i, key), made.root.querySelector(`tr[data-i="${i}"]`)); }, }); made.root.dataset.b09Table = tab; @@ -270,9 +231,8 @@ export function renderB09Estimation(root: HTMLElement): void { return made; }; const hint = spec.hint ? lineHint(note(spec.hint)) : null; // 236-1 안내 글 = 한 줄 - // 171-3 묶음 탭(일위대가표 · 중기사용료 · 단가산출근거) = 묶음 카드 기본 · 머리 단추로 표 + // 171-3 묶음 탭(일위대가표 · 중기사용료 · 단가산출근거) = 묶음 카드 기본 · 좌측 하단 스위치로 표(304-1) if (isGroupTab(tab)) { - const redraw = (): void => show(tab); return void panel.replaceChildren( ...renderGroupPanel({ tab, @@ -281,7 +241,6 @@ export function renderB09Estimation(root: HTMLElement): void { focus, goto, makeTable, - redraw, setTools: bars.setTools, }), ); @@ -375,9 +334,8 @@ export function renderB09Estimation(root: HTMLElement): void { // 카드 = 좌측 패널 공용 카드(269 · 「구조물 배치」 양식) const section = createPanelCard("데이터 점검"); section.append(refresh, check.list); - const actions = document.createElement("div"); - actions.className = "ui-sidebar-actions"; - actions.append(save, reset); + // 303-1 · 304-1 하단 고정 줄 — [박스보기|호표보기] · [하이퍼링크|팝업] · 초안 단추 · [?][저장][내역 초기화] + const actions = createDock([save, reset], () => isGroupTab(current) && show(current)); const left = document.createElement("div"); left.className = "ui-sidebar-panel b09-options"; left.append(section, ...DRAFT.panels.map((make) => make(() => user, setUser)), actions); diff --git a/B09_Estimation/B09_Estimation_UI_Tabs.ts b/B09_Estimation/B09_Estimation_UI_Tabs.ts index 97d937b6e..b17177b4b 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs.ts @@ -123,12 +123,14 @@ export type TabBuild = (ctx: TabContext) => TabTable | string; /** 초안 탭(252 · `draft/*_UI.ts` 가 TABS · TAB_GROUPS 와 함께 넣음 · 폴더째 지우면 빈 채로) — * builders = 탭 짜기 · extend = 브라우저 셈 곁(셈 결과에 더함) · - * panels = 왼쪽 패널 카드(264 · 「데이터 점검」 아래 · 지금 사용자 값 · 고침 → 캐시 · 다시 셈) */ + * panels = 왼쪽 패널 카드(264 · 「데이터 점검」 아래 · 지금 사용자 값 · 고침 → 캐시 · 다시 셈) · + * actions = 좌측 하단 고정 줄 단추(303-1 · [저장] 줄 위 한 줄 · 비면 줄 없음) */ export const DRAFT: { builders: Record; extend: Array<(payload: Row, user: Row, sheets: Row) => void>; panels: Array<(user: () => Row, setUser: (next: Row) => void) => HTMLElement>; -} = { builders: {}, extend: [], panels: [] }; + actions: Array<() => HTMLElement>; +} = { builders: {}, extend: [], panels: [], actions: [] }; /** 201-1 호표 묶음 레벨(보기 전용) — 머리 · 합계(제목 줄) = 1 · 그 밖 = 2 · key 열을 들여씀 */ export const blockLevel = (key: string, top: (index: number) => boolean): TabTable["level"] => ({ diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts index 641818e31..5ffe20bb2 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts @@ -1,22 +1,22 @@ /* ============================================================================= * B09_Estimation_UI_Tabs_Groups.ts * B09 묶음 탭 셋(PLAN 171-3) — 일위대가표 · 중기사용료 · 단가산출근거 = 공용 묶음 카드(171-2) 기본 · - * [표로 보기] = 공용 표 그대로(맞은편 단추 = [박스보기]). + * 호표보기 = 공용 표 그대로 · 고름 = 좌측 하단 [박스보기|호표보기] 한 벌(304-1 · 셋 탭 함께). * * 카드 = 탭 표 줄(`TabTable.rows`)을 호표(줄 키 앞머리)마다 묶음 — 값 · 풍선 · 누름 이동은 표와 같음 * 머리 줄 = 묶음 첫 줄 · 합계 줄 = 키 끝 `|합계` · `|총계` · 그 밖 = 몸 줄(표 줄 번호 그대로) * 목록표에서 온 호표(181-2) = 박스 붉은 외곽선 · 표 보기 = 그 호표 줄 구간 전체 붉은 외곽선 * 248-1 빨강 하나(공용 고름 강조 안 붙임 · 스크롤만) · 줄 · 카드 누름 · 방향키 · Esc = 빨강 풂 · * 누른 줄만 고름(is-focus) · Esc = 고름도 풂 - * 보기 · 가로 열 수 기억 = `b_page_state`(`b09-group-view` · `b09-group-cols` · 탭마다) · 못 읽으면 카드 · 2 - * 보기 단추 · 가로 열 수(202-1) = 페이지 보조 탭 줄 오른쪽(`setTools`) · 안내 글 = 표 · 카드 판 맨 아래 + * 보기 기억 = `b_page_state`(`b09-group-view` · 한 값 · 옛 탭별 값은 첫 읽기에 하나로) · 가로 열 수 = + * `b09-group-cols`(탭마다) · 못 읽으면 카드 · 2 + * 가로 열 수(202-1) = 페이지 보조 탭 줄 오른쪽(`setTools`) · 안내 글 = 표 · 카드 판 맨 아래 * 안내 글 길면 한 줄 줄임표 + 공용 호버로 전문(200-1) * 278-2 단가산출근거 박스 = 머리 줄(`|머리n`)을 운반 단가 창 ③ 칸 꼴로(`headSteps` 한 벌 · 몸 위 칸) · * 출처 = 그 칸 맨 위 · 호표 줄 · 총계 = 그대로 · 묶음 자료(`tabGroups`) · 표는 안 바뀜 * ========================================================================== */ import type { DataRow, DataTable } from "@ui/ui_template_data_table"; -import { createButton } from "@ui/ui_template_elements"; import { attachHoverInfo } from "@ui/ui_template_hover_info"; import { clampColumns, @@ -38,10 +38,8 @@ export const GROUP_TABS: TabId[] = ["dg", "machine_blocks", "sg"]; export const isGroupTab = (tab: TabId): boolean => GROUP_TABS.includes(tab); -/** 단추 글 = 바꿀 보기 이름 */ -export const viewLabel = (view: GroupView): string => (view === "cards" ? "표로 보기" : "박스보기"); - -export const flipView = (view: GroupView): GroupView => (view === "cards" ? "table" : "cards"); +/** 단추 글 = 그 보기 이름(304-1 좌측 스위치) */ +export const viewLabel = (view: GroupView): string => (view === "cards" ? "박스보기" : "호표보기"); type Saved = Record | null; @@ -53,19 +51,39 @@ const saved = (name: "b09-group-view" | "b09-group-cols"): Saved => { } }; -/** 고른 보기 — 모르는 값 · 못 읽음 = 카드 */ -export const readGroupView = (tab: TabId, kept: Saved = saved("b09-group-view")): GroupView => - kept?.[tab] === "table" ? "table" : "cards"; +/** 고른 보기(셋 탭 한 값) — 옛 탭별 값 = dg · 다른 탭 차례 · 모르는 값 · 못 읽음 = 카드 */ +export function readGroupView(kept: unknown = saved("b09-group-view")): GroupView { + const old = kept && typeof kept === "object" ? (kept as Record) : null; + const value = old ? (old.dg ?? old.machine_blocks ?? old.sg) : kept; + return value === "table" ? "table" : "cards"; +} + +/** 이 화면 안 보기(기억이 안 돼도 고른 보기 유지) */ +let chosen: GroupView | null = null; + +/** 지금 보기 — 첫 읽기에 옛 탭별 값을 한 값으로 옮겨 적음 */ +export function groupView(): GroupView { + if (chosen) return chosen; + const kept = saved("b09-group-view"); + chosen = readGroupView(kept); + if (kept && typeof kept === "object") chooseGroupView(chosen); + return chosen; +} + +export function chooseGroupView(view: GroupView): void { + chosen = view; + try { + writeState("b09-group-view", view); + } catch { + // 기억 못 해도 이 화면 안에서는 고른 값 그대로 + } +} /** 고른 가로 열 수 — 1~4 · 모르는 값 = 2 */ export const readGroupCols = (tab: TabId, kept: Saved = saved("b09-group-cols")): number => clampColumns(kept?.[tab]); -export function rememberGroup( - name: "b09-group-view" | "b09-group-cols", - tab: TabId, - value: unknown, -): void { +export function rememberGroup(name: "b09-group-cols", tab: TabId, value: unknown): void { try { writeState(name, { ...(saved(name) ?? {}), [tab]: value }); } catch { @@ -244,33 +262,18 @@ export function unpickOnRelease(root: HTMLElement): void { listenKeys(); } -/** 이 화면 안 보기(기억이 안 돼도 바꾼 보기 유지) */ -const chosen = new Map(); - -/** 묶음 탭 — 카드 = 카드 판 + 안내 글 · 표 = 표 + 안내 글 · 보기 단추 · 열 수 = `setTools`(보조 탭 줄 오른쪽) */ +/** 묶음 탭 — 카드 = 카드 판 + 안내 글 · 표 = 표 + 안내 글 · 열 수 = `setTools`(보조 탭 줄 오른쪽) */ export function renderGroupPanel(o: { tab: TabId; spec: TabTable; hint: HTMLElement | null; focus?: string; - goto: (link: Link | null) => void; + goto: (link: Link | null, anchor?: Element | null) => void; makeTable: (rowClass?: (index: number) => string | null) => DataTable; - redraw: () => void; setTools: (tools: HTMLElement[]) => void; }): HTMLElement[] { - const view = chosen.get(o.tab) ?? readGroupView(o.tab); + const view = groupView(); const lead = lineHint(o.hint); - const toggle = createButton({ - label: viewLabel(view), - variant: "ghost", - size: "sm", - onClick: () => { - chosen.set(o.tab, flipView(view)); - rememberGroup("b09-group-view", o.tab, flipView(view)); - o.redraw(); - }, - }); - toggle.dataset.b09GroupView = view; const at = o.focus === undefined ? -1 : rowIndex(o.spec.rows, o.focus); if (view === "cards") { const first = o.spec.columns[0]?.key ?? ""; @@ -281,19 +284,18 @@ export function renderGroupPanel(o: { : tabGroups(o.tab, o.spec), columns: readGroupCols(o.tab), onColumns: (n) => rememberGroup("b09-group-cols", o.tab, n), - onPick: (i) => o.goto(o.spec.link?.(i, first) ?? null), + onPick: (i) => + o.goto(o.spec.link?.(i, first) ?? null, cards.querySelector(`[data-at="${i}"]`)), focus: at, money: numberFormat(o.spec, "sum"), qty: numberFormat(o.spec, "qty"), - actions: [toggle], - placeTools: o.setTools, // 202-1 가로 열 수 · 보기 단추 = 보조 탭 줄 오른쪽 + placeTools: o.setTools, // 202-1 가로 열 수 = 보조 탭 줄 오른쪽 }); cards.dataset.b09Table = o.tab; live = { root: cards, unpick: (esc) => unpickCards(cards, esc) }; listenKeys(); return [cards, ...(lead ? [lead] : [])]; } - o.setTools([toggle]); let range = pickRange(o.spec.rows, o.focus); let chose = -1; // 누른 줄(다시 그려도 유지) const table = o.makeTable((i) => (i === chose ? "is-focus" : pickClass(range, i))); diff --git a/B09_Estimation/draft/B09_Draft_Print_Cost.ts b/B09_Estimation/draft/B09_Draft_Print_Cost.ts new file mode 100644 index 000000000..a05fc2abd --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Print_Cost.ts @@ -0,0 +1,96 @@ +/* ============================================================================= + * B09_Draft_Print_Cost.ts + * 인쇄 미리보기(PLAN 302-4 · 초안) 「원가계산」 영역 — B09 18장을 실무 내역서 차례로 모음. + * 차례 · 이름 · 방향 = 기번3 내역서 xlsx 시트(목차 · 안내 빼고 · page_setup.orientation) + * 값 = GET …/estimation + 화면 캐시(`b09-estimation`) → `computeSheets` = B09 화면과 같은 값 + * 표 = 화면 표 짜기(`…_UI_Builders`) 그대로 · 링크 · 강조 · 고칠 칸 표시 · 단가연결 칸만 뺌 + * 공기 초안 탭(공기집계 · 공기산출) = 내역서 차례 밖 → 안 넣음 + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import type { DataTableOptions } from "@ui/ui_template_data_table"; +import { readState } from "../../A00_Common/b_page_state"; +import type { Row } from "../B09_Estimation_Calc_Base"; +import { tableOptionsOf, tabTableOf } from "../B09_Estimation_UI_Builders"; +import { computeSheets } from "../B09_Estimation_UI_Shell"; +import type { TabContext, TabId } from "../B09_Estimation_UI_Tabs"; +import type { CollectSheets, PrintSheet } from "./B09_Draft_Print_Types"; + +type Orientation = NonNullable; + +/** 기번3 내역서 시트 차례 — [탭, 시트 이름, 인쇄 방향] */ +export const COST_SHEETS: Array<[TabId, string, Orientation]> = [ + ["cost", "공사원가계산서", "landscape"], + ["total", "총괄설계내역서", "landscape"], + ["bill", "설계내역서", "landscape"], + ["dg_list", "일위대가목록표", "landscape"], + ["dg", "일위대가표", "landscape"], + ["sg_list", "단가산출근거목록표", "landscape"], + ["sg", "단가산출근거", "landscape"], + ["basis", "환율및기초자료", "landscape"], + ["machine", "중기목록표", "landscape"], + ["machine_blocks", "중기사용료", "landscape"], + ["material", "재료비목록표", "landscape"], + ["labor", "노무비목록표", "landscape"], + ["expense", "경비목록표", "landscape"], + ["compare", "자재단가대비표", "landscape"], + ["sum_material", "재료비수량금액집계표", "landscape"], + ["sum_labor", "노무비수량금액집계표", "landscape"], + ["sum_expense", "경비수량금액집계표", "landscape"], + ["sum_machine", "중기시간금액집계표", "landscape"], +]; + +/** 화면 조작 전용 칸(설계내역서 「단가연결 ▾」) */ +const UI_COLUMNS = new Set(["connect"]); +/** 인쇄에 남길 줄 꼴(제목 줄 굵게) — 손 줄 · 고른 줄 표시는 뺌 */ +const KEEP_ROW = new Set(["is-title"]); + +/** 화면 표 옵션 → 인쇄 표(값 · 머리 · 레벨 · 합계 · 병합 그대로 · 누름 · 고침 · 강조 뺌) */ +export function printTable(options: DataTableOptions, caption: string): DataTableOptions { + const { columns, rows, total, frozen, empty, level, mergeSame, rowClass } = options; + return { + columns: columns.filter((c) => !UI_COLUMNS.has(c.key)), + rows, + total, + frozen, + empty, + caption, + mergeSame, + level: level && { ...level, tree: false }, + rowClass: + rowClass && + ((i) => + rowClass(i) + ?.split(" ") + .filter((c) => KEEP_ROW.has(c)) + .join(" ") || null), + }; +} + +/** 받은 자료 + 사용자 값 → 18장(차례 그대로 · 표가 없는 탭 = 빈 표) */ +export function costSheetsOf(payload: Row, user: Row): PrintSheet[] { + const ctx: TabContext = { + sheets: computeSheets(payload, user), + payload, + user, + setUser: () => {}, + }; + return COST_SHEETS.map(([id, name, orientation]) => { + const spec = tabTableOf(id, ctx); + const table = + !spec || typeof spec === "string" + ? { columns: [], rows: [], caption: name } + : printTable(tableOptionsOf(spec, id), name); + return { id, name, orientation, table }; + }); +} + +export const collectCostSheets: CollectSheets = async (projectId) => { + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/estimation`, { + credentials: "include", + }); + if (!response.ok) throw new Error(`원가 자료를 불러오지 못했습니다 — HTTP ${response.status}`); + const payload = (await response.json()) as Row; + const user = readState("b09-estimation", projectId) ?? payload.user ?? {}; + return costSheetsOf(payload, user); +}; diff --git a/B09_Estimation/draft/B09_Draft_Print_Drawings.ts b/B09_Estimation/draft/B09_Draft_Print_Drawings.ts new file mode 100644 index 000000000..7f3907355 --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Print_Drawings.ts @@ -0,0 +1,59 @@ +/* ============================================================================= + * B09_Draft_Print_Drawings.ts + * 인쇄 미리보기 도면 영역(PLAN 302-2) — 서버가 만든 목록 · PDF 를 그대로 받음(보는 것 = 받는 것). + * 차례 = B07 도면번호 → 구조물 조합 도면(도번마다 한 번) · 서버 `B09_Draft_Print_Drawings_Router.py` + * PDF 머리 X-Aislo-Skipped-Images = 못 넣은 그림 {id: 수} → `skippedImagesOf(blob)` + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import type { DrawingItem, FetchDrawingList, FetchDrawingsPdf } from "./B09_Draft_Print_Types"; + +/** 도면 한 장 — 약속 꼴(id · name = label) + 서버가 주는 칸 */ +export interface PrintDrawingItem extends DrawingItem { + source: "b07" | "structure"; + kind: string; + label: string; + number: string; + confirmed: boolean; + paper: string | null; + /** PDF 를 만들어야 셈 — 목록에서는 null */ + images_skipped: number | null; +} + +async function failure(response: Response): Promise { + const body = (await response.json().catch(() => null)) as { message?: string } | null; + return new Error(body?.message ?? `HTTP ${response.status}`); +} + +export const fetchDrawingList: FetchDrawingList = async (projectId) => { + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/print/drawings`, { + credentials: "include", + }); + if (!response.ok) throw await failure(response); + const items = (await response.json()) as Omit[]; + return items.map((item): PrintDrawingItem => ({ ...item, name: item.label })); +}; + +const skipped = new WeakMap>(); + +export const fetchDrawingsPdf: FetchDrawingsPdf = async (projectId, ids) => { + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/print/drawings-pdf`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(ids ? { ids } : {}), + }); + if (!response.ok) throw await failure(response); + const blob = await response.blob(); + try { + skipped.set(blob, JSON.parse(response.headers.get("X-Aislo-Skipped-Images") ?? "{}")); + } catch { + /* 머리가 깨졌으면 빠진 그림 없음으로 */ + } + return blob; +}; + +/** 그 PDF 에서 못 넣은 그림 {도면 id: 수} — 없으면 빈 꼴 */ +export function skippedImagesOf(blob: Blob): Record { + return skipped.get(blob) ?? {}; +} diff --git a/B09_Estimation/draft/B09_Draft_Print_Drawings_Node.ts b/B09_Estimation/draft/B09_Draft_Print_Drawings_Node.ts new file mode 100644 index 000000000..c3b936efe --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Print_Drawings_Node.ts @@ -0,0 +1,34 @@ +/* ============================================================================= + * B09_Draft_Print_Drawings_Node.ts + * 인쇄 미리보기 도면(PLAN 302-2) — 미확정 횡단 장에 구조물을 얹는 Node 진입점(CLAUDE.md 5장 ②). + * + * B07 화면이 미확정 장을 열 때 얹는 것과 **같은 코드**(`placeStructureEntities`)를 서버에서 돌린다. + * 입력 상세 = 정본(`get_section_detail`) — 화면 캐시의 저장 안 한 편집은 안 실림(인쇄 = 저장본). + * + * 실행: node <번들> <입력.json> <출력.json> + * 입력 { detail: 종횡단 상세, drawings: { [도면 id]: CAD 도면 } } + * 출력 { drawings: { [도면 id]: 구조물을 얹은 CAD 도면 } } + * 끝 코드: 0 성공 / 2 인자 오류 + * ========================================================================== */ + +import { readFileSync, writeFileSync } from "node:fs"; +import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch"; +import "../../B07_DesignDetail/B07_DesignDetail_Node_Dom"; // 최소 DOM — 그리기 코드보다 먼저 +import { placeStructureEntities } from "../../B07_DesignDetail/B07_DesignDetail_Cross_Overlay"; + +type Drawing = Parameters[1]; + +const [inputPath, outputPath] = process.argv.slice(2); +if (!inputPath || !outputPath) { + console.error("사용법: node <번들> <입력.json> <출력.json>"); + process.exit(2); +} + +const input = JSON.parse(readFileSync(inputPath, "utf8")) as { + detail?: SectionDetailResponse; + drawings?: Record; +}; +const sections = input.detail?.cross_sections ?? []; +const drawings = input.drawings ?? {}; +for (const drawing of Object.values(drawings)) placeStructureEntities(sections, drawing); +writeFileSync(outputPath, JSON.stringify({ drawings }), "utf8"); diff --git a/B09_Estimation/draft/B09_Draft_Print_Drawings_Pdf.py b/B09_Estimation/draft/B09_Draft_Print_Drawings_Pdf.py new file mode 100644 index 000000000..117ccfb53 --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Print_Drawings_Pdf.py @@ -0,0 +1,337 @@ +"""인쇄 미리보기 도면(PLAN 302-2) — CAD 도면(openwebcad JSON) 한 장 → PDF 한 쪽(벡터). + +쪽 = 도각 — 도각 층 범위를 그 양식 실치수(A1 840×594 · 회사 양식이면 그 크기) 종이에 맞춤 · +도각이 없으면 그림 범위 1:1. 숨긴 층 · 동결 층은 뺌(화면에 안 보이는 것 = 안 나감). + +그리기 규칙 = openwebcad 화면(밝은 바탕)과 같게: + 색 = `theme.ts` `paintColor` 밝은 바탕 짝(자동색 → 검정 · 휘도 0.58 넘는 색 → 0.3 으로 누름) + 글 = 기준점 세로 가운데(textBaseline middle) · 정렬 left/center/right(기본 center) · + 방향 벡터 회전 + 호 = 세계 좌표 반시계(counterClockwise 거짓이면 시계) · 점 = 반지름 5 원 · + 그림 = 칸 안 비율 지켜 가운데 + 표 = `B07 Frame_Export.table_parts`(openwebcad table-geometry 거울) 그대로 +한글 = 시스템 TTF(맑은 고딕 → Noto Sans KR → 나눔고딕) 심음 · 끝에 문서가 글꼴을 줄임(subset). +""" + +from __future__ import annotations + +import base64 +import math +import re +from functools import lru_cache +from pathlib import Path +from typing import Any + +import pymupdf + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import FRAME_LAYER_ID +from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Export import table_parts +from B07_DesignDetail.B07_DesignDetail_Engine_Template import entities_bbox, template_entities + +PT_PER_MM = 72 / 25.4 +#: 화면 선 굵기(px) → 종이(pt) — ponytail: 고정 배율, 출력물 보고 고칠 손잡이 +LINE_PT_PER_PX = 0.5 +DASH_PT_PER_PX = 1.0 +POINT_RADIUS = 5.0 +DEFAULT_FONT_MM = 3.0 +#: 구조물 조합 도면에 얹는 도각 층(M02 `STRUCTURE_FRAME_LAYER`) +STRUCTURE_FRAME_LAYER = "m02-structure-frame" +#: 쪽을 잡는 도각 층 — 앞이 먼저 +#: (구조물 도면은 불러온 DXF 도각 `b08-frame` 이 그림 안에 있어도 얹은 도각) +FRAME_LAYERS = (STRUCTURE_FRAME_LAYER, FRAME_LAYER_ID) +PAPERS = {"A1": (840.0, 594.0), "A3": (420.0, 297.0)} + +FONT_FILES = ( + "C:/Windows/Fonts/malgun.ttf", + "C:/Windows/Fonts/NotoSansKR-VF.ttf", + "/usr/share/fonts/truetype/nanum/NanumGothic.ttf", + "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", +) +_AUTO_COLORS = {"#000000", "#ffffff", "#f5f7fa", "black", "white"} + + +@lru_cache(maxsize=1) +def _font() -> pymupdf.Font: + for path in FONT_FILES: + if Path(path).is_file(): + return pymupdf.Font(fontfile=path) + raise RuntimeError("한글 글꼴(맑은 고딕 · Noto Sans KR · 나눔고딕)을 찾지 못했습니다.") + + +def _hex(color: str) -> str: + value = color.strip().lower() + short = re.fullmatch(r"#([0-9a-f])([0-9a-f])([0-9a-f])", value) + return "#" + "".join(c * 2 for c in short.groups()) if short else value + + +def paint_color(color: Any) -> tuple[float, float, float]: + """`theme.ts` `paintColor`(밝은 바탕) 짝 — 거울 시험 `test_302_2_print_drawings`.""" + value = _hex(str(color or "#ffffff")) + if value in _AUTO_COLORS or not re.fullmatch(r"#[0-9a-f]{6}", value): + return (0.0, 0.0, 0.0) + r, g, b = (int(value[i : i + 2], 16) for i in (1, 3, 5)) + luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255 + if luminance > 0.58: + scale = 0.3 / luminance + r, g, b = (round(min(255, v * scale)) for v in (r, g, b)) + return (r / 255, g / 255, b / 255) + + +def _xy(point: Any) -> tuple[float, float] | None: + if isinstance(point, dict) and isinstance(point.get("x"), (int, float)): + return float(point["x"]), float(point["y"]) + return None + + +def hidden_layers(drawing: dict[str, Any]) -> set[str]: + return { + str(layer.get("id")) + for layer in drawing.get("layers") or [] + if isinstance(layer, dict) and (layer.get("isVisible") is False or layer.get("isFrozen")) + } + + +def visible_entities(drawing: dict[str, Any]) -> list[dict[str, Any]]: + """숨긴 · 동결 층 개체를 (자식까지) 뺀 목록.""" + hidden = hidden_layers(drawing) + + def keep(entity: Any) -> dict[str, Any] | None: + if not isinstance(entity, dict) or entity.get("layerId") in hidden: + return None + children = entity.get("children") + if not children: + return entity + return {**entity, "children": [c for c in map(keep, children) if c]} + + return [e for e in map(keep, drawing.get("entities") or []) if e] + + +def paper_name(width: float, height: float) -> str: + for name, (w, h) in PAPERS.items(): + if abs(width - w) < 5 and abs(height - h) < 5: + return name + return f"{width:.0f}×{height:.0f}mm" + + +def page_frame(entities: list[dict[str, Any]]) -> tuple[tuple[float, ...], tuple[float, float]]: + """(세계 범위 x0, y0, x1, y1 · 종이 mm (가로, 세로)) — 도각 층 범위 → 양식 실치수 종이.""" + for layer in FRAME_LAYERS: + frame = entities_bbox([e for e in entities if e.get("layerId") == layer]) + if not frame: + continue + source = entities_bbox(template_entities()) + if source: + width, height = source[2] - source[0], source[3] - source[1] + return frame, (width, height) + box = entities_bbox(entities) or (0.0, 0.0, 840.0, 594.0) + return box, (max(box[2] - box[0], 1.0), max(box[3] - box[1], 1.0)) + + +_KAPPA = 0.5523 # 원 = 베지어 넷 + + +def _g(value: float) -> str: + return f"{value:.3f}".rstrip("0").rstrip(".") + + +class _Painter: + """세계 좌표(mm · y 위로) → PDF 그리기 명령(pt). + + 같은 선 모양 = 한 묶음 · 글 = 색마다 TextWriter. + + 선은 명령 글을 목록에 모아 한 번에 붙임 — + pymupdf Shape 를 개체마다 부르면 장 하나 수만 줄에 수십 초. + """ + + def __init__(self, page: pymupdf.Page, world: tuple[float, ...], font: pymupdf.Font): + self.page = page + self.height = page.rect.height + self.x0, self.y1 = world[0], world[3] + width, height = max(world[2] - world[0], 1e-9), max(world[3] - world[1], 1e-9) + self.k = min(page.rect.width / width, page.rect.height / height) + # 도각 비율이 종이와 다르면 가운데 + self.ox = (page.rect.width - width * self.k) / 2 + self.oy = (page.rect.height - height * self.k) / 2 + self.font = font + self.parts: list[str] = [] + self.ops: list[str] = [] + self.style: tuple | None = None + self.writers: dict[tuple, pymupdf.TextWriter] = {} + self.turned: list[tuple[pymupdf.TextWriter, pymupdf.Point, float]] = [] + self.skipped = 0 + + def p(self, x: float, y: float) -> pymupdf.Point: + """쪽 좌표(y 아래로) — 글 · 그림 자리.""" + return pymupdf.Point((x - self.x0) * self.k + self.ox, (self.y1 - y) * self.k + self.oy) + + def q(self, x: float, y: float) -> str: + """PDF 좌표(y 위로) 글 — 선 명령.""" + page_y = (self.y1 - y) * self.k + self.oy + return f"{_g((x - self.x0) * self.k + self.ox)} {_g(self.height - page_y)}" + + def stroke(self, entity: dict[str, Any], fill: tuple | None = None) -> None: + width = max(float(entity.get("lineWidth") or 1), 0.1) * LINE_PT_PER_PX + dash = tuple(float(d) * DASH_PT_PER_PX for d in entity.get("lineDash") or []) + style = (paint_color(entity.get("lineColor")), width, dash, fill) + if style != self.style: + self.flush() + self.style = style + + def flush(self) -> None: + if self.style is None or not self.ops: + return + (r, g, b), width, dash, fill = self.style + head = f"q 1 J 1 j {_g(r)} {_g(g)} {_g(b)} RG {_g(width)} w [{' '.join(map(_g, dash))}] 0 d" + if fill: + head += f" {_g(fill[0])} {_g(fill[1])} {_g(fill[2])} rg" + self.parts.append(f"{head}\n{''.join(self.ops)}{'B' if fill else 'S'} Q\n") + self.ops = [] + + def line(self, points: list[tuple[float, float]]) -> None: + self.ops.append( + f"{self.q(*points[0])} m " + " ".join(f"{self.q(*xy)} l" for xy in points[1:]) + "\n" + ) + + def circle(self, center: tuple[float, float], radius: float) -> None: + cx, cy = center + c = radius * _KAPPA + q = self.q + self.ops.append( + f"{q(cx + radius, cy)} m " + f"{q(cx + radius, cy + c)} {q(cx + c, cy + radius)} {q(cx, cy + radius)} c " + f"{q(cx - c, cy + radius)} {q(cx - radius, cy + c)} {q(cx - radius, cy)} c " + f"{q(cx - radius, cy - c)} {q(cx - c, cy - radius)} {q(cx, cy - radius)} c " + f"{q(cx + c, cy - radius)} {q(cx + radius, cy - c)} {q(cx + radius, cy)} c\n" + ) + + def text(self, label: str, base: tuple[float, float], options: dict[str, Any]) -> None: + if not label.strip(): + return + size = float(options.get("fontSize") or DEFAULT_FONT_MM) * self.k + width = self.font.text_length(label, fontsize=size) + align = options.get("textAlign") or "center" + shift = {"left": 0.0, "right": width}.get(align, width / 2) + direction = _xy(options.get("textDirection")) or (1.0, 0.0) + angle = math.degrees(math.atan2(direction[1], direction[0])) + anchor = self.p(*base) + color = paint_color(options.get("textColor")) + # 기준점 = 세로 가운데 → 글자 바닥선은 (올림 + 내림) / 2 만큼 아래 + drop = (self.font.ascender + self.font.descender) / 2 * size + if abs(angle) > 1e-6: + writer = pymupdf.TextWriter(self.page.rect, color=color) + self.turned.append((writer, anchor, angle)) + else: + writer = self.writers.setdefault(color, pymupdf.TextWriter(self.page.rect, color=color)) + writer.append(anchor + (-shift, drop), label, font=self.font, fontsize=size) + + def finish(self) -> None: + """선 → 글 차례로 붙임(글이 선 위).""" + self.flush() + if self.parts: + shape = self.page.new_shape() + shape.totalcont = "".join(self.parts) + shape.commit() + for writer in self.writers.values(): + writer.write_text(self.page) + for writer, anchor, angle in self.turned: + writer.write_text(self.page, morph=(anchor, pymupdf.Matrix(-angle))) + + def image(self, entity: dict[str, Any]) -> None: + shape = entity.get("shapeData") or {} + points = [xy for v in shape.get("points") or [] if (xy := _xy(v))] + data = str(shape.get("imageData") or "") + found = re.match(r"data:([\w/+.-]+);base64,(.*)", data, re.S) + if len(points) < 2 or not found: + self.skipped += 1 + return + xs, ys = [x for x, _ in points], [y for _, y in points] + rect = pymupdf.Rect(self.p(min(xs), max(ys)), self.p(max(xs), min(ys))) + blob = base64.b64decode(found.group(2)) + try: + if "svg" in found.group(1): + with pymupdf.open(stream=blob, filetype="svg") as svg: + with pymupdf.open("pdf", svg.convert_to_pdf()) as pdf: + self.page.show_pdf_page(rect, pdf, 0, keep_proportion=True) + else: + self.page.insert_image(rect, stream=blob, keep_proportion=True) + except (RuntimeError, ValueError): + self.skipped += 1 + + def arc(self, center: tuple[float, float], radius: float, shape: dict[str, Any]) -> None: + start, end = float(shape["startAngle"]), float(shape["endAngle"]) + if shape.get("counterClockwise") is False: + start, end = end, start + sweep = (end - start) % (2 * math.pi) or 2 * math.pi + steps = max(8, int(sweep / (math.pi / 36))) + self.line( + [ + ( + center[0] + radius * math.cos(start + sweep * i / steps), + center[1] + radius * math.sin(start + sweep * i / steps), + ) + for i in range(steps + 1) + ] + ) + + def entity(self, entity: dict[str, Any]) -> None: + shape = entity.get("shapeData") or {} + kind = entity.get("type") + if kind == "Image": + self.image(entity) + return + if kind == "Table": + self.stroke(entity) + lines, texts = table_parts(shape) + for x1, y1, x2, y2 in lines: + self.line([(x1, y1), (x2, y2)]) + color = (shape.get("style") or {}).get("textColor") + for one in texts: # ponytail: 칸 글색 · 굵게는 표 글색 하나로 + options = {"fontSize": one["height"], "textAlign": one["align"], "textColor": color} + self.text(one["text"], (one["x"], one["y"]), options) + return + if kind == "Hatch": + ring = [xy for v in shape.get("points") or [] if (xy := _xy(v))] + if len(ring) >= 3: + options = shape.get("options") or {} + # ponytail: 무늬(선 · 격자) 해치는 색 테두리만 — 칸 채움 무늬가 필요하면 clip 경로로 + fill = ( + paint_color(options.get("color")) if options.get("style") == "solid" else None + ) + self.stroke(entity, fill) + self.line([*ring, ring[0]]) + return + start, end = _xy(shape.get("startPoint")), _xy(shape.get("endPoint")) + if start and end: + self.stroke(entity) + self.line([start, end]) + elif (base := _xy(shape.get("basePoint"))) and isinstance(shape.get("label"), str): + self.text(shape["label"], base, shape.get("options") or {}) + elif point := _xy(shape.get("point")): + self.stroke(entity) + self.circle(point, POINT_RADIUS) + elif (center := _xy(shape.get("center"))) and isinstance(shape.get("radius"), (int, float)): + self.stroke(entity) + angles = (shape.get("startAngle"), shape.get("endAngle")) + if all(isinstance(a, (int, float)) for a in angles): + self.arc(center, float(shape["radius"]), shape) + else: + self.circle(center, float(shape["radius"])) + else: + vertices = [xy for v in shape.get("points") or [] if (xy := _xy(v))] + if len(vertices) >= 2: + self.stroke(entity) + self.line([*vertices, vertices[0]]) + for child in entity.get("children") or []: + self.entity(child) + + +def add_drawing_page(doc: pymupdf.Document, drawing: dict[str, Any]) -> tuple[str, int]: + """도면 한 장을 쪽 하나로 덧붙임 → (종이 이름, 못 넣은 그림 수).""" + entities = visible_entities(drawing) + world, (width_mm, height_mm) = page_frame(entities) + page = doc.new_page(width=width_mm * PT_PER_MM, height=height_mm * PT_PER_MM) + painter = _Painter(page, world, _font()) + for entity in entities: + painter.entity(entity) + painter.finish() + return paper_name(width_mm, height_mm), painter.skipped diff --git a/B09_Estimation/draft/B09_Draft_Print_Drawings_Router.py b/B09_Estimation/draft/B09_Draft_Print_Drawings_Router.py new file mode 100644 index 000000000..e87803cb7 --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Print_Drawings_Router.py @@ -0,0 +1,99 @@ +"""인쇄 미리보기 도면(PLAN 302-2) — 목록 · PDF 길. + + GET /api/projects/{id}/print/drawings + → [{id, source, kind, label, number, confirmed, paper, images_skipped}] + POST /api/projects/{id}/print/drawings-pdf {ids?} → application/pdf + 목록 차례 · 장마다 한 쪽 · 머리 X-Aislo-Pages · + X-Aislo-Skipped-Images(못 넣은 그림 {id: 수} JSON) + +로그인 · 회사 · 프로젝트 권한은 이 라우터가 스스로 검(B09 라우터가 include 하든 앱이 바로 붙이든). +저장 안 함 — 매번 만듦 · B07 정본 · 확정 상태 그대로. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from uuid import UUID + +import pymupdf +from fastapi import APIRouter, Depends, Response +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from B09_Estimation.draft import B09_Draft_Print_Drawings_Source as source +from B09_Estimation.draft.B09_Draft_Print_Drawings_Pdf import add_drawing_page +from common_util.common_util_auth import require_company, require_project_access, verify_session + +logger = logging.getLogger(__name__) +router = APIRouter( + prefix="/api/projects", + tags=["B09 Print Drawings (draft)"], + dependencies=[ + Depends(verify_session), + Depends(require_company), + Depends(require_project_access), + ], +) + + +class DrawingsPdfRequest(BaseModel): + ids: list[str] | None = None + + +def _error(status: int, message: str) -> JSONResponse: + return JSONResponse(status_code=status, content={"status": "error", "message": message}) + + +@router.get("/{project_id}/print/drawings") +async def get_print_drawings(project_id: UUID) -> JSONResponse: + try: + items, _ = await source.drawing_items(project_id) + except Exception: + logger.exception("인쇄 도면 목록 실패: %s", project_id) + return _error(500, "도면 목록을 읽지 못했습니다.") + return JSONResponse(items) + + +def _pdf(drawings: list[dict], ids: list[str]) -> tuple[bytes, dict[str, int]]: + doc = pymupdf.open() + skipped: dict[str, int] = {} + for drawing_id, drawing in zip(ids, drawings, strict=True): + _, missing = add_drawing_page(doc, drawing) + if missing: + skipped[drawing_id] = missing + doc.subset_fonts() # 한글 글꼴 = 쓴 글자만 + return doc.tobytes(garbage=3, deflate=True), skipped + + +@router.post("/{project_id}/print/drawings-pdf") +async def post_print_drawings_pdf( + project_id: UUID, request: DrawingsPdfRequest | None = None +) -> Response: + try: + items, context = await source.drawing_items(project_id) + by_id = {item["id"]: item for item in items} + ids = request.ids if request and request.ids is not None else list(by_id) + unknown = [one for one in ids if one not in by_id] + if unknown: + return _error(400, f"목록에 없는 도면입니다: {', '.join(unknown)}") + if not ids: + return _error(404, "인쇄할 도면이 없습니다.") + chosen = [by_id[one] for one in ids] + drawings = await source.drawings_in_order(project_id, chosen, context) + body, skipped = await asyncio.to_thread(_pdf, drawings, ids) + except ValueError as exc: + return _error(400, str(exc)) + except Exception: + logger.exception("인쇄 도면 PDF 실패: %s", project_id) + return _error(500, "도면 PDF 를 만들지 못했습니다.") + return Response( + body, + media_type="application/pdf", + headers={ + "Content-Disposition": 'inline; filename="drawings.pdf"', + "X-Aislo-Pages": str(len(ids)), + "X-Aislo-Skipped-Images": json.dumps(skipped), + }, + ) diff --git a/B09_Estimation/draft/B09_Draft_Print_Drawings_Source.py b/B09_Estimation/draft/B09_Draft_Print_Drawings_Source.py new file mode 100644 index 000000000..20beef2f6 --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Print_Drawings_Source.py @@ -0,0 +1,240 @@ +"""인쇄 미리보기 도면(PLAN 302-2) — 목록 · 도면 모으기. + +차례 = B07 도면번호(B07 목록 그대로) → +구조물 조합 도면(도번 차례 · 도번마다 한 번 · `도면` 다음 `더한장`). +B07 도면 = B07 단건 조회 그대로(확정본 = 저장 JSON · 미확정 = 서버 조립) + 미확정 횡단 장 구조물 = +B07 화면과 같은 TS 를 Node 로(`B09_Draft_Print_Drawings_Node.ts` · CLAUDE.md 5장 ②). +구조물 도면 = 프로젝트 사본 `도면.json` + 얹는 도각(M02 `structure_frame` · 장 도번). +저장 · 확정 상태는 손대지 않음 — 매번 읽어 만듦. +""" + +from __future__ import annotations + +import asyncio +import logging +import subprocess +from pathlib import Path +from typing import Any +from uuid import UUID + +from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( + entities_bbox, + template_entities, + use_project_templates, + use_title_fields, +) +from B09_Estimation.draft.B09_Draft_Print_Drawings_Pdf import paper_name +from common_util.common_util_node_bundle import ROOT, bundle_stale, node_env, run_bundle_json + +logger = logging.getLogger(__name__) + +B07_PREFIX = "b07:" +STRUCTURE_PREFIX = "structure:" +_ENTRY = Path(__file__).with_name("B09_Draft_Print_Drawings_Node.ts") +# 번들은 git 밖(tmp) — package.json 을 안 건드리려고 vite 를 바로 부름 +BUNDLE = ROOT / "tmp" / "print_drawings_node" / "B09_Draft_Print_Drawings_Node.js" +_BUILD = [ + "node", + "./config/node_modules/vite/bin/vite.js", + "build", + "--configLoader", + "runner", + "--ssr", + "../B09_Estimation/draft/B09_Draft_Print_Drawings_Node.ts", + "--outDir", + "../tmp/print_drawings_node", +] + + +async def project_root(project_id: UUID) -> Path: + from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path + from common_util.common_util_storage import resolve_stored_project_path + from config.config_db import get_db_pool + + async with get_db_pool().acquire() as connection: + stored = await get_project_storage_relative_path(connection, project_id) + return Path(resolve_stored_project_path(stored)).resolve() + + +async def title_fields(project_id: UUID) -> dict[str, str]: + from B07_DesignDetail.B07_DesignDetail_Router import title_block_fields + + return await title_block_fields(project_id) + + +async def b07_list(project_id: UUID) -> tuple[int | None, list[dict[str, Any]]]: + """(노선 id, B07 목록) — 노선 미확정 · 자료 없음이면 (None, []).""" + from B07_DesignDetail.B07_DesignDetail_Router import get_design_drawing_list + + response = await get_design_drawing_list(project_id) + if not hasattr(response, "drawings"): # JSONResponse = 404 · 409 · 500 + return None, [] + return response.route_id, [item.model_dump() for item in response.drawings] + + +async def b07_drawing(project_id: UUID, drawing_id: str) -> dict[str, Any]: + from B07_DesignDetail.B07_DesignDetail_Router import get_design_drawing + + response = await get_design_drawing(project_id, drawing_id) + if not hasattr(response, "drawing"): + raise ValueError(f"B07 도면 「{drawing_id}」 을 읽지 못했습니다.") + return response.model_dump() + + +async def section_detail(project_id: UUID, route_id: int) -> dict[str, Any] | None: + from B06_Section.B06_Section_Router import get_section_detail + + response = await get_section_detail(project_id, route_id) + dump = getattr(response, "model_dump", None) + return dump(mode="json") if dump else None + + +def _bundle_ready() -> bool: + stale = bundle_stale(BUNDLE) or _ENTRY.stat().st_mtime > BUNDLE.stat().st_mtime + if not stale: + return True + result = subprocess.run( # noqa: S603 — 고정 명령 + _BUILD, cwd=str(ROOT), env=node_env(), capture_output=True, text=True, timeout=300 + ) + if result.returncode != 0: + logger.error("인쇄 도면 Node 번들 빌드 실패:\n%s", result.stderr) + return result.returncode == 0 + + +def place_structures(detail: dict[str, Any], drawings: dict[str, dict[str, Any]]) -> bool: + """미확정 횡단 장에 구조물을 얹음(제자리) — 실패면 거짓(서버 그림만 나감).""" + if not drawings or not _bundle_ready(): + return False + output = run_bundle_json(BUNDLE, "", {"detail": detail, "drawings": drawings}) + placed = (output or {}).get("drawings") if isinstance(output, dict) else None + if not isinstance(placed, dict): + return False + for key, drawing in placed.items(): + if key in drawings and isinstance(drawing, dict): + drawings[key].clear() + drawings[key].update(drawing) + return True + + +def sheet_number(number: str, index: int) -> str: + """장 도번 — 1장 = 도번 · n 째 장 = 도번-n(M02 `sheetNumber` 와 같음).""" + return "" if not number else number if index == 0 else f"{number}-{index + 1}" + + +def structure_sheets(root: Path) -> list[dict[str, Any]]: + """구조물 조합 도면 장 — 도번 차례 · 같은 도번은 한 번 · {id, label, number, drawing}.""" + from M02_MasterTemplete.M02_Item_Snapshot import linked_snapshots, structures_dir + from M02_MasterTemplete.M02_Item_Source import read_json + + docs: dict[str, tuple[str, dict[str, Any]]] = {} + for owner, copy in linked_snapshots(root).items(): + if "도면" not in copy["부분"]: + continue + doc = read_json(structures_dir(root) / copy["폴더"] / "도면.json") + if not isinstance(doc, dict) or not isinstance(doc.get("도면"), dict): + continue + number = str(doc.get("도번") or "") + name = str((copy.get("항목") or {}).get("name") or copy["폴더"]) + docs.setdefault(number or f"~{owner}", (name, doc)) + sheets = [] + for key in sorted(docs): + name, doc = docs[key] + number = "" if key.startswith("~") else key + pages = [{"이름": "", "도면": doc["도면"]}, *(doc.get("더한장") or [])] + for index, page in enumerate(pages): + if not isinstance(page, dict) or not isinstance(page.get("도면"), dict): + continue + label = f"{name} · {page['이름']}" if page.get("이름") else name + numbered = sheet_number(number, index) or f"{key.lstrip('~')}-{index + 1}" + sheets.append( + { + "id": f"{STRUCTURE_PREFIX}{numbered}", + "label": label, + "number": sheet_number(number, index), + "drawing": page["도면"], + } + ) + return sheets + + +def with_frame(drawing: dict[str, Any], number: str) -> dict[str, Any]: + """구조물 도면 + 얹는 도각(M02 화면과 같은 `structure_frame` · 표제란 = 프로젝트 값).""" + from M02_MasterTemplete.M02_MasterTemplete_Router_Drawing import structure_frame + + frame = structure_frame(number) + layers = [*(drawing.get("layers") or []), *([frame["layer"]] if frame["layer"] else [])] + return { + **drawing, + "entities": [*frame["entities"], *drawing.get("entities", [])], + "layers": layers, + } + + +async def drawing_items(project_id: UUID) -> tuple[list[dict[str, Any]], dict[str, Any]]: + """(목록, 만들 때 쓸 문맥). + + 목록 칸 = {id, source, kind, label, number, confirmed, paper, images_skipped}. + """ + root = await project_root(project_id) + use_project_templates(root) + template = entities_bbox(template_entities()) + paper = paper_name(template[2] - template[0], template[3] - template[1]) if template else None + route_id, b07 = await b07_list(project_id) + items = [ + { + "id": f"{B07_PREFIX}{item['id']}", + "source": "b07", + "kind": item["kind"], + "label": item["label"], + "number": str(number), + "confirmed": bool(item.get("confirmed")), + "paper": paper, + "images_skipped": None, # PDF 만들 때 셈 — 응답 머리 X-Aislo-Skipped-Images + } + for number, item in enumerate(b07, start=1) + ] + sheets = await asyncio.to_thread(structure_sheets, root) + items += [ + { + "id": sheet["id"], + "source": "structure", + "kind": "structure", + "label": sheet["label"], + "number": sheet["number"], + "confirmed": True, + "paper": paper, + "images_skipped": None, + } + for sheet in sheets + ] + return items, {"root": root, "route_id": route_id, "sheets": {s["id"]: s for s in sheets}} + + +async def drawings_in_order( + project_id: UUID, items: list[dict[str, Any]], context: dict[str, Any] +) -> list[dict[str, Any]]: + """고른 목록 차례대로 CAD 도면 — 미확정 횡단 장은 구조물을 얹어서.""" + base_fields = await title_fields(project_id) + drawings: list[dict[str, Any]] = [] + unconfirmed_cross: dict[str, dict[str, Any]] = {} + for item in items: + if item["source"] == "b07": + response = await b07_drawing(project_id, item["id"][len(B07_PREFIX) :]) + drawing = response["drawing"] + if item["kind"] == "cross" and not response.get("confirmed"): + unconfirmed_cross[item["id"]] = drawing + else: + sheet = context["sheets"][item["id"]] + use_project_templates(context["root"]) + use_title_fields(base_fields) + drawing = with_frame(sheet["drawing"], sheet["number"]) + drawings.append(drawing) + if unconfirmed_cross and context.get("route_id") is not None: + detail = await section_detail(project_id, int(context["route_id"])) + if detail is None or not await asyncio.to_thread( + place_structures, detail, unconfirmed_cross + ): + logger.warning( + "인쇄 도면 — 미확정 횡단 장 구조물을 못 얹음(서버 그림만): %s", project_id + ) + return drawings diff --git a/B09_Estimation/draft/B09_Draft_Print_Page.css b/B09_Estimation/draft/B09_Draft_Print_Page.css new file mode 100644 index 000000000..a9f0bfb1a --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Print_Page.css @@ -0,0 +1,80 @@ +/* 인쇄 미리보기(PLAN 302-1 · 초안) — 상태 한 줄 + PDF 보기 칸(창 아래 끝까지). 색은 테마 변수만. */ + +.b09-print { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + height: calc(100vh - var(--spacing-64)); + padding: var(--spacing-16) var(--spacing-24); + box-sizing: border-box; +} + +.b09-print__status { + margin: 0; + min-height: 1.4em; + font-size: var(--text-caption); + color: var(--color-text-secondary); +} + +.b09-print__status.is-error { + color: var(--color-danger); +} + +.b09-print__frame { + flex: 1; + width: 100%; + min-height: 0; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface, var(--color-canvas)); +} + +.b09-print__areas { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4); +} + +.b09-print__list { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + max-height: 50vh; + overflow: auto; +} + +.b09-print__item { + display: flex; + align-items: center; + gap: var(--spacing-8); + font-size: var(--text-body-sm); + cursor: pointer; +} + +/* 좌측 하단 두 줄(302-5 · B09 Dock 꼴) — 단추는 줄 폭을 나눠 가짐 · 가로 넘침 없음 */ +.b09-print__actions.ui-sidebar-actions { + flex-direction: column; +} + +.b09-print__actions.ui-sidebar-actions > * { + flex: none; +} + +.b09-print__actions .b09-print__row { + display: flex; + gap: var(--btn-group-gap); + min-width: 0; +} + +.ui-sidebar-actions.b09-print__actions .b09-print__row > .ui-btn:not(.ui-help-btn) { + flex: 1 1 0; + min-width: 0; + padding-inline: var(--spacing-4); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.b09-print__actions .b09-print__row > .ui-btn[hidden] { + display: none; +} diff --git a/B09_Estimation/draft/B09_Draft_Print_Page.ts b/B09_Estimation/draft/B09_Draft_Print_Page.ts new file mode 100644 index 000000000..913f74859 --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Print_Page.ts @@ -0,0 +1,313 @@ +/* ============================================================================= + * B09_Draft_Print_Page.ts + * 인쇄 미리보기 페이지(PLAN 302-1 · 요구 192-3 · 초안) — 주소 #/b09-print(`…_Print_Route`). + * 좌측 = 영역 셋(도면 · 수량산출 · 원가계산) 고르기 + 그 영역 쪽 목록(체크로 빼고 넣기 · 차례 그대로) + + * [PDF 내려받기] [엑셀 내려받기](도면 = PDF 만) + [B09 로 돌아가기] + * 본문 = 고른 쪽들의 PDF 를 브라우저 PDF 보기로(보는 것 = 받는 것) · 만드는 동안 작은 표시(잠금 없음) · + * 실패 = 까닭 한 줄 · 체크를 바꾸면 다시 만듦(최신 요청만 채택) + * 영역 = 다른 창 파일(`…_Print_Quantity` collectQuantitySheets · `…_Print_Cost` collectCostSheets · + * `…_Print_Drawings` fetchDrawingList · fetchDrawingsPdf) — 없으면 「준비 중」 + * 표 영역 PDF · 엑셀 = 서버(`B09_Draft_Print_Router` table-pdf · table-xlsx) · 서버는 꼴만 바꿈 + * ========================================================================== */ + +import "@ui/ui_template_general_layout.css"; +import "./B09_Draft_Print_Page.css"; +import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; +import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible"; +import { createButton } from "@ui/ui_template_elements"; +import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; +import type { MountedPage } from "../../A00_Common/router"; +import { navigateTo } from "../../A00_Common/router"; +import { workflowSteps } from "../../A00_Common/b_page_scaffold"; +import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../../A00_Common/b_workflow_nav"; +import { inlineImages, pdfBody, xlsxBody } from "./B09_Draft_Print_Table"; +import type { + CollectSheets, + DrawingItem, + FetchDrawingList, + FetchDrawingsPdf, + PrintSheet, +} from "./B09_Draft_Print_Types"; + +type AreaId = "drawings" | "quantity" | "cost"; + +const AREAS: { id: AreaId; label: string; file: string }[] = [ + { id: "drawings", label: "도면", file: "./B09_Draft_Print_Drawings.ts" }, + { id: "quantity", label: "수량산출", file: "./B09_Draft_Print_Quantity.ts" }, + { id: "cost", label: "원가계산", file: "./B09_Draft_Print_Cost.ts" }, +]; + +/** 영역 파일(다른 창 몫) — 없는 파일은 목록에 없음 = 「준비 중」 */ +const MODS = import.meta.glob([ + "./B09_Draft_Print_Drawings.ts", + "./B09_Draft_Print_Quantity.ts", + "./B09_Draft_Print_Cost.ts", +]); + +/** 영역 한 벌 — 쪽 목록 · 고른 쪽 PDF · 엑셀(도면 없음) */ +interface Area { + items: DrawingItem[]; + pdf: (ids: string[]) => Promise; + xlsx?: (ids: string[]) => Promise; + /** 그 PDF 곁 알림(도면 = 못 넣은 그림 수) */ + note?: (pdf: Blob) => string; +} + +async function post(projectId: string, path: string, body: unknown): Promise { + const res = await fetch(`${API_BASE_URL}/projects/${projectId}/print/${path}`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const detail = ((await res.json().catch(() => null)) as { detail?: unknown } | null)?.detail; + throw new Error(typeof detail === "string" ? detail : `HTTP ${res.status}`); + } + return res.blob(); +} + +/** 표 영역 — 쪽 = PrintSheet · PDF · 엑셀 = 서버 꼴 바꿈 */ +function tableArea(projectId: string, title: string, sheets: PrintSheet[]): Area { + const pick = (ids: string[]) => ids.map((id) => sheets.find((s) => s.id === id)!); + return { + items: sheets.map(({ id, name }) => ({ id, name })), + pdf: async (ids) => { + const picked = await Promise.all(pick(ids).map(inlineImages)); + return post(projectId, "table-pdf", pdfBody(title, picked)); + }, + xlsx: (ids) => post(projectId, "table-xlsx", xlsxBody(title, pick(ids))), + }; +} + +/** 영역 파일 읽기 — 없음 = null(준비 중) */ +async function loadArea(projectId: string, id: AreaId): Promise { + const def = AREAS.find((a) => a.id === id)!; + const load = MODS[def.file]; + if (!load) return null; + const mod = (await load()) as Record; + if (id === "drawings") { + const list = mod.fetchDrawingList as FetchDrawingList | undefined; + const pdf = mod.fetchDrawingsPdf as FetchDrawingsPdf | undefined; + if (!list || !pdf) return null; + const skipped = mod.skippedImagesOf as ((b: Blob) => Record) | undefined; + const note = (b: Blob) => { + const n = Object.values(skipped?.(b) ?? {}).reduce((a, v) => a + v, 0); + return n ? ` · 못 넣은 그림 ${n}개` : ""; + }; + return { items: await list(projectId), pdf: (ids) => pdf(projectId, ids), note }; + } + const collect = mod[id === "quantity" ? "collectQuantitySheets" : "collectCostSheets"] as + CollectSheets | undefined; + return collect ? tableArea(projectId, def.label, await collect(projectId)) : null; +} + +function save(blob: Blob, name: string): void { + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = name; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +export function renderB09Print(root: HTMLElement): MountedPage { + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + + const status = document.createElement("p"); + status.className = "b09-print__status"; + status.dataset.b09PrintStatus = ""; + const frame = document.createElement("iframe"); + frame.className = "b09-print__frame"; + frame.title = "인쇄 미리보기"; + const body = document.createElement("div"); + body.className = "b09-print"; + body.append(status, frame); + + const areaCard = createPanelCard("영역"); + const areaRow = document.createElement("div"); + areaRow.className = "b09-print__areas"; + areaCard.append(areaRow); + const sheetCard = createPanelCard("쪽 목록"); + const list = document.createElement("div"); + list.className = "b09-print__list"; + sheetCard.append(list); + + let area: Area | null = null; + let areaId: AreaId = AREAS[0].id; + let off = new Set(); // 체크 뺀 쪽 + let seq = 0; + let shown: { url: string; blob: Promise } | null = null; + let timer = 0; + + const label = () => AREAS.find((a) => a.id === areaId)!.label; + const say = (text: string, error = false) => { + status.textContent = text; + status.classList.toggle("is-error", error); + }; + const picked = () => (area?.items ?? []).map((it) => it.id).filter((id) => !off.has(id)); + const clearFrame = () => { + if (shown?.url) URL.revokeObjectURL(shown.url); + shown = null; + frame.removeAttribute("src"); + }; + + /** 고른 쪽 PDF 다시 만듦 — 최신 요청만 보임 · 내려받기는 같은 PDF */ + const rebuild = () => { + const my = ++seq; + const ids = picked(); + clearFrame(); + if (!area) return; + if (!ids.length) return say("고른 쪽이 없습니다."); + say(`${label()} PDF 만드는 중…`); + const blob = area.pdf(ids); + shown = { url: "", blob }; + blob.then( + (b) => { + if (my !== seq) return; + shown!.url = URL.createObjectURL(b); + frame.src = shown!.url; + say(`${label()} · ${ids.length}장 묶음${area?.note?.(b) ?? ""}`); + }, + (error: unknown) => my === seq && say(`PDF 를 못 만들었습니다 — ${errText(error)}`, true), + ); + }; + const later = () => { + clearTimeout(timer); + timer = window.setTimeout(rebuild, 300); + }; + + const pdfBtn = createButton({ + label: "PDF 내려받기", + onClick: () => { + if (!shown) return; + const name = `${label()}.pdf`; + shown.blob.then( + (b) => save(b, name), + () => undefined, + ); + }, + }); + pdfBtn.dataset.b09PrintPdf = ""; + const xlsxBtn = createButton({ + label: "엑셀 내려받기", + variant: "ghost", + onClick: () => { + const ids = picked(); + if (!area?.xlsx || !ids.length) return; + const name = `${label()}.xlsx`; + say(`${label()} 엑셀 만드는 중…`); + area.xlsx(ids).then( + (b) => (save(b, name), say(`${label()} 엑셀을 내려받았습니다.`)), + (error: unknown) => say(`엑셀을 못 만들었습니다 — ${errText(error)}`, true), + ); + }, + }); + xlsxBtn.dataset.b09PrintXlsx = ""; + const back = createButton({ + label: "B09 로 돌아가기", + variant: "ghost", + onClick: () => navigateTo(ROUTES.B09_ESTIMATION), + }); + + const drawList = () => { + list.replaceChildren( + ...(area?.items ?? []).map((it) => { + const row = document.createElement("label"); + row.className = "b09-print__item"; + const box = document.createElement("input"); + box.type = "checkbox"; + box.checked = !off.has(it.id); + box.dataset.b09PrintItem = it.id; + box.addEventListener("change", () => { + if (box.checked) off.delete(it.id); + else off.add(it.id); + later(); + }); + const name = document.createElement("span"); + name.textContent = it.name; + row.append(box, name); + return row; + }), + ); + }; + + const pickArea = (id: AreaId) => { + areaId = id; + area = null; + off = new Set(); + const my = ++seq; + clearFrame(); + areaRow.replaceChildren( + ...AREAS.map((a) => { + const b = createButton({ + label: a.label, + size: "sm", + variant: a.id === id ? "filled" : "ghost", + onClick: () => a.id !== areaId && pickArea(a.id), + }); + b.dataset.b09PrintArea = a.id; + return b; + }), + ); + xlsxBtn.hidden = id === "drawings"; + drawList(); + if (!projectId) return say("현재 프로젝트가 없습니다.", true); + say(`${label()} 쪽 목록 읽는 중…`); + loadArea(projectId, id).then( + (got) => { + if (my !== seq) return; + if (!got) return say(`${label()} — 준비 중`); + area = got; + drawList(); + if (!got.items.length) return say(`${label()} — 쪽이 없습니다.`); + rebuild(); + }, + (error: unknown) => + my === seq && say(`${label()} 목록을 못 읽었습니다 — ${errText(error)}`, true), + ); + }; + + // 두 줄(302-5) — 위 = 내려받기 · 아래 = [?](도움말 줄) · 돌아가기 — 패널 폭 넘침 없음 + const files = document.createElement("div"); + files.className = "b09-print__row"; + files.append(pdfBtn, xlsxBtn); + const nav = document.createElement("div"); + nav.className = "b09-print__row"; + nav.dataset.helpRow = ""; + nav.append(back); + const actions = document.createElement("div"); + actions.className = "ui-sidebar-actions b09-print__actions"; + actions.append(files, nav); + const left = document.createElement("div"); + left.className = "ui-sidebar-panel b09-print__panel"; + left.append(areaCard, sheetCard, actions); + attachCollapsible(left); + + const layout = createWorkflowLayout({ + title: "인쇄 미리보기", + steps: workflowSteps(), + activeStep: 6, + mainContent: body, + leftPanel: left, + routes: WORKFLOW_STEP_ROUTES, + onStepClick: (stepIndex: number) => { + if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); + }, + }); + root.append(layout.root); + pickArea(areaId); + + return { + covers: () => false, + show: () => undefined, + dispose: () => { + seq++; + clearTimeout(timer); + clearFrame(); + }, + }; +} + +const errText = (error: unknown): string => + error instanceof Error ? error.message : String(error); diff --git a/B09_Estimation/draft/B09_Draft_Print_Quantity.ts b/B09_Estimation/draft/B09_Draft_Print_Quantity.ts new file mode 100644 index 000000000..96a06d90f --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Print_Quantity.ts @@ -0,0 +1,185 @@ +/* ============================================================================= + * B09_Draft_Print_Quantity.ts + * 인쇄 미리보기(PLAN 302-3 · 초안) 「수량산출」 영역 — B08 탭 표를 화면 없이 PrintSheet 로 모음. + * + * 차례 = B08 탭 차례 — 구조물 집계표 → 자재 셋(총괄 · 콘크리트소요 · 자재수량) → 토공 넷(토공집계표 · + * 토적표 · 평균운반거리 · 종방향 무대운반거리) → 수리집수면적표(배수지점 측점마다 한 장) → 거리이정표 + * 값 = B08 화면과 같은 길(서버 GET + 화면 캐시 · [저장] 전 값이면 화면에 보이는 그 값) · + * 표 짜기 = B08 화면이 쓰는 같은 함수(`B08_Quantity_*_Table(s)`) + * 수리 = 화면 계산서(두 카드)를 구분 · 항목 · 값 표로 · 계산 = 서버(화면과 같은 POST) + * 거리이정표 = 표 + 경로 지도 그림(사용자 그림 우선 · 없으면 서버 캡처 PNG · data: 주소) + * 영역 하나가 못 읽히면 그 자리에 까닭 한 장(나머지는 그대로) + * 구조물 조합 도면은 여기 없음(도면 영역 · 302-2) + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import type { DataTableOptions } from "@ui/ui_template_data_table"; +import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; +import { stationLabel } from "../../common_util/common_util_station"; +import { + EARTHWORK_SHEETS, + earthworkSheetOptions, + earthworkSummaryOptions, + HAUL_SHEETS, + haulOptions, + loadEarthwork, +} from "../../B08_Quantity/B08_Quantity_Earthwork_Tables"; +import { + hydroCalc, + hydroInputs, + hydroTable, + loadHydro, +} from "../../B08_Quantity/B08_Quantity_Hydro_Table"; +import { + loadMaterials, + materialOptions, + materialTable, + type MaterialSheetId, +} from "../../B08_Quantity/B08_Quantity_Material_Tables"; +import { milestoneTable } from "../../B08_Quantity/B08_Quantity_Milestone_Table"; +import { loadSummary, summaryTable } from "../../B08_Quantity/B08_Quantity_Summary_Table"; +import { + liveMilestone, + loadMilestone, + routeImages, + siteInfo, +} from "../../B08_Quantity/B08_Quantity_UI_Milestone_State"; +import type { CollectSheets, PrintSheet } from "./B09_Draft_Print_Types"; + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +/** 자재 보조 탭 차례(B08 화면 `SHEETS`) */ +const MATERIAL_SHEETS: Array<[MaterialSheetId, keyof typeof ui_locales]> = [ + ["material", "B08_Quantity_Tab_MaterialTotal"], + ["concrete", "B08_Quantity_Tab_ConcreteMaterial"], + ["quantity", "B08_Quantity_Tab_MaterialQuantity"], +]; + +/** 표 한 장 — 열이 많으면 가로 쪽 */ +const sheet = (id: string, name: string, table: DataTableOptions): PrintSheet => ({ + id, + name, + table, + orientation: table.columns.length > 8 ? "landscape" : "portrait", +}); + +/** 까닭 한 장 — 못 읽은 영역 · 빈 표 자리 */ +const noteSheet = (id: string, name: string, text: string): PrintSheet => + sheet(id, name, { + columns: [{ key: "note", label: "알림", width: 480 }], + rows: [{ note: text }], + }); + +/** 영역 하나 — 실패하면 까닭 한 장 */ +const area = (id: string, name: string, make: () => Promise): Promise => + make().catch((error: unknown) => [ + noteSheet( + id, + name, + `불러오지 못했습니다 — ${error instanceof Error ? error.message : String(error)}`, + ), + ]); + +async function summarySheets(projectId: string): Promise { + const name = L("B08_Quantity_Tab_StructureSummary"); + const view = await loadSummary(projectId); + if (!view.stations.length) + return [noteSheet("summary", name, L("B08_Quantity_Summary_NoStation"))]; + return [sheet("summary", name, summaryTable(view).options)]; +} + +async function materialSheets(projectId: string): Promise { + const { payload, overrides, calc } = await loadMaterials(projectId); + const sheets = calc(payload, overrides); + return MATERIAL_SHEETS.map(([id, label]) => + sheet(`material:${id}`, L(label), materialOptions(materialTable(id, sheets, overrides))), + ); +} + +async function earthworkSheets(projectId: string): Promise { + const { table, interval } = await loadEarthwork(projectId); + return EARTHWORK_SHEETS.map(({ id, label }) => { + const key = `earthwork:${id}`; + if (id === "summary") return sheet(key, label, earthworkSummaryOptions(table)); + if (id === "sheet") + return table.rows.length + ? sheet(key, label, earthworkSheetOptions(table, interval)) + : noteSheet(key, label, "측점 단면적이 아직 없습니다 — 횡단 설계를 먼저 마치세요."); + const { groups, caption } = HAUL_SHEETS[id](table); + const { options, averages } = haulOptions(groups, caption); + // 화면 표 아래 평균운반거리 줄 = 쪽엔 본문 끝 줄(번호 칸 글) + return sheet(key, label, { + ...options, + rows: [...options.rows, ...averages.map((no) => ({ no }))], + }); + }); +} + +async function hydroSheets(projectId: string): Promise { + const head = L("B08_Quantity_Tab_Hydrology"); + const { sheet: payload, interval, drafts } = await loadHydro(projectId); + if (!payload.stations.length) + return [ + noteSheet( + "hydro", + head, + "저장된 배수지점(관 지점)이 없습니다 — 배수유역 · 종단에서 관을 저장하세요.", + ), + ]; + return Promise.all( + payload.stations.map(async (station) => { + const inputs = hydroInputs(payload, station, drafts); + const result = await hydroCalc(projectId, inputs).catch(() => null); + const label = stationLabel(station.chainage_m, interval); + return sheet( + `hydro:${station.chainage_m.toFixed(3)}`, + `${head} ${label}${station.kind ? ` ${station.kind}` : ""}`, + hydroTable(label, station.kind, inputs, result, payload), + ); + }), + ); +} + +/** 그림 → data: 주소(서버 PDF 가 쿠키 없이 그림) */ +async function dataUrl(url: string): Promise { + const response = await fetch(url, { credentials: "include" }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const blob = await response.blob(); + const bytes = new Uint8Array(await blob.arrayBuffer()); + let text = ""; + for (let i = 0; i < bytes.length; i += 0x8000) + text += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); + return `data:${blob.type || "image/png"};base64,${btoa(text)}`; +} + +async function milestoneSheets(projectId: string): Promise { + const name = L("B08_Quantity_Tab_Milestone"); + await loadMilestone(projectId, true); + const data = liveMilestone(projectId); + const { options } = milestoneTable(data, siteInfo()?.mid_default_m ?? null); + const images = routeImages(); + const pictures = await Promise.all( + data.routes + .filter((route) => images[route.id]) + .map(async (route, i) => ({ + src: await dataUrl( + `${API_BASE_URL}/projects/${projectId}/quantity/milestone/image?route=${encodeURIComponent(route.id)}`, + ).catch(() => ""), + caption: route.name.trim() || route.rows[0]?.item || `거래처 ${i + 1}`, + })), + ); + return [{ ...sheet("milestone", name, options), images: pictures.filter((p) => p.src) }]; +} + +export const collectQuantitySheets: CollectSheets = async (projectId) => { + const parts = await Promise.all([ + area("summary", L("B08_Quantity_Tab_StructureSummary"), () => summarySheets(projectId)), + area("material", L("B08_Quantity_Tab_Materials"), () => materialSheets(projectId)), + area("earthwork", L("B08_Quantity_Tab_Earthwork"), () => earthworkSheets(projectId)), + area("hydro", L("B08_Quantity_Tab_Hydrology"), () => hydroSheets(projectId)), + area("milestone", L("B08_Quantity_Tab_Milestone"), () => milestoneSheets(projectId)), + ]); + return parts.flat(); +}; diff --git a/B09_Estimation/draft/B09_Draft_Print_Route.ts b/B09_Estimation/draft/B09_Draft_Print_Route.ts new file mode 100644 index 000000000..de86318d3 --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Print_Route.ts @@ -0,0 +1,15 @@ +/* ============================================================================= + * B09_Draft_Print_Route.ts + * 인쇄 미리보기 길 선언(PLAN 302-1 · 초안) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록. + * 주소 = #/b09-print · 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 — 폴더째 지우면 길도 없음 + * ========================================================================== */ + +import type { DraftRoute } from "../../A00_Common/router"; + +export const ROUTE: DraftRoute = { + path: "b09-print", + workflow: true, + project: true, + footerless: true, + load: async () => (await import("./B09_Draft_Print_Page")).renderB09Print, +}; diff --git a/B09_Estimation/draft/B09_Draft_Print_Router.py b/B09_Estimation/draft/B09_Draft_Print_Router.py new file mode 100644 index 000000000..dd7841c95 --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Print_Router.py @@ -0,0 +1,352 @@ +"""인쇄 미리보기 표 출력 길(PLAN 302-1 · 초안) — 서버는 꼴만 바꿈(값 셈 · 저장 없음 · 정본 아님). + + POST /api/projects/{id}/print/table-pdf {title, sheets:[{name, orientation, html}]} + html = 브라우저가 `renderDataTableHtml` 로 만든 표(화면과 같은 꼴) → 인쇄 CSS 를 입혀 + 헤드리스 Edge(없으면 Chrome)로 A4 PDF 한 벌 · 시트 = 차례대로 새 쪽 · 방향 = 시트마다 · + 쪽 머리 = 시트 이름(표 머리와 함께 쪽마다 되풀이) · 쪽 바닥 = 쪽 번호 · + 넓은 표 = 쪽 폭에 맞춰 줄임 + POST /api/projects/{id}/print/table-xlsx {title, sheets:[{name, grid}]} + grid(`B09_Draft_Print_Table.sheetGrid`) → 엑셀 — 시트 = 차례대로 · 이름 31자 · + 금지 글자 바꿈 · 머리 굵게 · 병합 · 테두리 · 숫자 = 숫자 칸(열 소수 서식) · + 레벨 = 들여쓰기 · 열 폭 · 인쇄 = 시트 방향 · A4 · 폭 1쪽 · 머리 줄 반복 + +`B09_Estimation_Router` 가 draft/*_Router.py 의 router 를 끼워 넣음 +(같은 머리 · 같은 권한 = 회사 · 프로젝트). +PDF 는 자식 파이썬 프로세스(이 파일을 스크립트로 · 입력 JSON stdin · 결과 JSON 한 줄) — +서버 안 스레드에선 Playwright 가 드라이버를 못 띄움(`B08_Quantity_Milestone_Capture` 와 같은 까닭). +헤드리스 쪽은 스크립트 끔 · 바깥 요청 모두 막음(그림 = data: 주소만). +""" + +from __future__ import annotations + +import html as htmlmod +import io +import json +import re +import subprocess +import sys +import tempfile +import threading +from pathlib import Path +from typing import Any, Literal + +TIMEOUT_S = 90 +CHANNELS = ("msedge", "chrome") # 시스템 브라우저 — 번들 chromium 은 안 받아 둠 +NO_BROWSER = "서버에서 브라우저(Edge · Chrome)를 못 띄움" +#: A4 인쇄 칸 폭(px · 96dpi) — 210 · 297mm − 좌우 여백 10mm 둘 +PRINT_W = {"portrait": 190 / 25.4 * 96, "landscape": 277 / 25.4 * 96} +MIN_ZOOM = 0.3 + +PRINT_CSS = """ +@page { size: A4 portrait; margin: 10mm 10mm 14mm; } +@page wide { size: A4 landscape; margin: 10mm 10mm 14mm; } +html, body { margin: 0; } +body { font-family: "Malgun Gothic", "맑은 고딕", sans-serif; font-size: 11px; color: #000; } +.sheet { break-after: page; } +.sheet:last-child { break-after: auto; } +.sheet.is-landscape { page: wide; } +.ui-data-table__table { + width: var(--dt-w); table-layout: fixed; border-collapse: collapse; line-height: 1.3; +} +.ui-data-table__table caption { display: none; } +.ui-data-table__table th, .ui-data-table__table td { + height: 20px; padding: 0 4px; overflow: hidden; border: 0.5pt solid #666; + white-space: nowrap; white-space-collapse: preserve-spaces; text-overflow: clip; +} +.ui-data-table__table [data-num] { text-align: right; font-variant-numeric: tabular-nums; } +.ui-data-table__table td.is-empty { text-align: center; color: #555; } +.ui-data-table__table thead { display: table-header-group; } +.ui-data-table__table thead th { background: #eee; font-weight: 600; text-align: center; } +.ui-data-table__table thead th.sheet-title { + background: #fff; border: 0; text-align: left; font-size: 13px; font-weight: 700; height: 26px; +} +.ui-data-table__table tfoot { display: table-row-group; } +.ui-data-table__table tfoot td { background: #f3f3f3; font-weight: 700; } +.ui-data-table__table tbody tr.is-title td { font-weight: 700; } +.ui-data-table__table td.is-merged { vertical-align: middle; } +.ui-data-table__table tr { break-inside: avoid; } +.ui-data-table__table td.ui-data-table__lv { + text-align: left; padding-left: calc(4px + var(--dt-lv, 0) * 1em); +} +.ui-data-table__table td.ui-data-table__lv.is-node { font-weight: 700; } +.ui-data-table__guide, .ui-data-table__icon { display: none; } +.sheet-figure { margin: 8px 0 0; break-inside: avoid; } +.sheet-figure img { max-width: 100%; max-height: 240mm; } +.sheet-figure figcaption { font-size: 11px; margin-top: 2px; } +""" +FOOTER = ( + '
' + ' /
' +) + +_lock = threading.Lock() # ponytail: 서버 전체 한 줄 — 브라우저 한 번에 하나 + + +class PrintError(Exception): + """까닭 한 줄(예외 원문 없음).""" + + +def _titled(table: str, name: str) -> str: + """표 머리 맨 위에 시트 이름 줄 — thead 가 쪽마다 되풀이되므로 쪽 머리가 됨.""" + span = max(1, table.count("{htmlmod.escape(name)}' + return table.replace("", f"{row}", 1) if "" in table else table + + +def document_html(title: str, sheets: list[dict[str, Any]]) -> str: + """시트마다 한 덩이(새 쪽 · 방향) — 표 머리에 시트 이름.""" + parts = [ + f'
' + f"{_titled(s['html'], s['name'])}
" + for s in sheets + ] + return ( + f'{htmlmod.escape(title)}' + f"{''.join(parts)}" + ) + + +#: 시트 덩이마다 쪽 폭에 맞춰 줄임(zoom · 넓은 표만) +FIT_JS = ( + """(w) => { for (const s of document.querySelectorAll('.sheet')) { + const width = s.scrollWidth, max = w[s.classList.contains('is-landscape') ? 1 : 0]; + if (width > max) s.style.zoom = String(Math.max(%s, max / width)); } }""" + % MIN_ZOOM +) + + +def render_pdf(doc: str) -> bytes: + """헤드리스 브라우저 → PDF 바이트. 실패 = PrintError.""" + try: + from playwright.sync_api import Error as PwError + from playwright.sync_api import sync_playwright + except ImportError as error: + raise PrintError("서버에 Playwright 가 없습니다(pip install playwright)") from error + try: + with sync_playwright() as p: + browser = None + for channel in CHANNELS: + try: + browser = p.chromium.launch(channel=channel, headless=True) + break + except PwError: + continue + if browser is None: + raise PrintError(NO_BROWSER) + try: + context = browser.new_context(java_script_enabled=False) + context.route("**/*", lambda route: route.abort()) # 바깥 요청 없음(data: 만) + page = context.new_page() + page.set_content(doc, wait_until="load") + page.evaluate(FIT_JS, [PRINT_W["portrait"], PRINT_W["landscape"]]) + return page.pdf( + prefer_css_page_size=True, + print_background=True, + display_header_footer=True, + header_template="", + footer_template=FOOTER, + ) + finally: + browser.close() + except PrintError: + raise + except Exception as error: # 드라이버 못 뜸 등 — 예외 글은 내지 않음 + raise PrintError(NO_BROWSER) from error + + +def spawn_pdf(doc: str) -> bytes: + """자식 파이썬 프로세스로 render_pdf — 결과 PDF 는 임시 파일로 받음.""" + with _lock, tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "out.pdf" + try: + done = subprocess.run( + [sys.executable, __file__], + input=json.dumps({"doc": doc, "out": str(out)}).encode(), + capture_output=True, + timeout=TIMEOUT_S, + ) + except subprocess.TimeoutExpired as error: + raise PrintError(f"PDF 만들기가 {TIMEOUT_S}초 안에 안 끝남") from error + except OSError as error: + raise PrintError(NO_BROWSER) from error + try: + result = json.loads(done.stdout.decode().strip().splitlines()[-1]) + except (ValueError, IndexError): # 자식이 죽음 — stderr(예외 글)는 내지 않음 + result = {"reason": NO_BROWSER} + if result.get("reason"): + raise PrintError(result["reason"]) + return out.read_bytes() + + +# ── 엑셀 ────────────────────────────────────────────────────────────────── + +BAD_TITLE = re.compile(r"[\[\]:*?/\\]") + + +def sheet_title(name: str, used: set[str]) -> str: + """엑셀 시트 이름 — 금지 글자 = 「_」 · 31자 · 같은 이름 = 뒤에 (2)…""" + base = BAD_TITLE.sub("_", name).strip().strip("'") or "시트" + title, n = base[:31], 1 + while title.lower() in used: + n += 1 + tail = f" ({n})" + title = base[: 31 - len(tail)] + tail + used.add(title.lower()) + return title + + +def number_format(decimals: int | None) -> str: + return "#,##0" + ("." + "0" * decimals if decimals else "") + + +def print_setup(ws: Any, orientation: str, head_rows: int) -> None: + """인쇄 설정(302-5) — PDF 와 같은 방향 · A4 · 폭 1쪽 맞춤 · 머리 줄 쪽마다 · 여백 ≈ PDF.""" + from openpyxl.worksheet.page import PageMargins + + ws.page_setup.orientation = orientation + ws.page_setup.paperSize = ws.PAPERSIZE_A4 + ws.page_setup.fitToWidth = 1 + ws.page_setup.fitToHeight = 0 + ws.sheet_properties.pageSetUpPr.fitToPage = True + ws.page_margins = PageMargins(left=0.4, right=0.4, top=0.4, bottom=0.55, header=0.2, footer=0.2) + if head_rows: + ws.print_title_rows = f"1:{head_rows}" + ws.oddHeader.left.text = "&A" # 시트 이름 + ws.oddFooter.center.text = "&P / &N" # 쪽 번호 + + +def build_xlsx(sheets: list[dict[str, Any]]) -> bytes: + """grid 시트들 → 엑셀 바이트(차례 그대로).""" + from openpyxl import Workbook + from openpyxl.styles import Alignment, Border, Font, PatternFill, Side + from openpyxl.utils import get_column_letter + + thin = Side(style="thin", color="666666") + box = Border(left=thin, right=thin, top=thin, bottom=thin) + head_fill = PatternFill("solid", fgColor="EEEEEE") + wb = Workbook() + wb.remove(wb.active) + used: set[str] = set() + for sheet in sheets: + ws = wb.create_sheet(sheet_title(sheet["name"], used)) + g = sheet["grid"] + cols, top = g["cols"], g["head_rows"] + for j, col in enumerate(cols, 1): + ws.column_dimensions[get_column_letter(j)].width = max(4, round(col["w"] / 7, 1)) + for r in range(1, top + 1): + for j in range(1, len(cols) + 1): + cell = ws.cell(r, j) + cell.font, cell.fill, cell.border = Font(bold=True), head_fill, box + cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) + for h in g["head"]: + ws.cell(h["r"] + 1, h["c"] + 1, h["text"]) + if h["rs"] > 1 or h["cs"] > 1: + ws.merge_cells( + start_row=h["r"] + 1, + start_column=h["c"] + 1, + end_row=h["r"] + h["rs"], + end_column=h["c"] + h["cs"], + ) + lv_col = g.get("level_col") + for i, row in enumerate(g["rows"]): + r = top + 1 + i + bold = bool(g["bold"][i]) if i < len(g["bold"]) else False + level = g["levels"][i] if i < len(g["levels"]) else None + for j, value in enumerate(row): + cell = ws.cell(r, j + 1, value) + cell.border = box + if bold: + cell.font = Font(bold=True) + if isinstance(value, (int, float)) and not isinstance(value, bool): + cell.number_format = number_format(cols[j].get("dec")) + if j == lv_col and level: + cell.alignment = Alignment(indent=max(0, int(level) - 1)) + for m in g.get("merges", []): + ws.merge_cells( + start_row=top + 1 + m["r"], + start_column=m["c"] + 1, + end_row=top + m["r"] + m["rs"], + end_column=m["c"] + 1, + ) + ws.cell(top + 1 + m["r"], m["c"] + 1).alignment = Alignment(vertical="center") + if top: + ws.freeze_panes = ws.cell(top + 1, (g.get("frozen") or 0) + 1) + print_setup(ws, sheet.get("orientation") or "portrait", top) + out = io.BytesIO() + wb.save(out) + return out.getvalue() + + +if __name__ == "__main__": # 자식 프로세스 몸통 — fastapi 를 읽지 않음 + job = json.loads(sys.stdin.buffer.read()) + try: + Path(job["out"]).write_bytes(render_pdf(job["doc"])) + print(json.dumps({"reason": None})) + except PrintError as error: + print(json.dumps({"reason": str(error)}, ensure_ascii=False)) + sys.exit(0) + + +# ── 길 ──────────────────────────────────────────────────────────────────── + +import asyncio # noqa: E402 +from urllib.parse import quote # noqa: E402 +from uuid import UUID # noqa: E402 + +from fastapi import APIRouter, HTTPException # noqa: E402 +from fastapi.responses import Response # noqa: E402 +from pydantic import BaseModel, Field # noqa: E402 + +router = APIRouter() +MAX_SHEETS = 200 + + +class PdfSheet(BaseModel): + name: str = Field(max_length=200) + orientation: Literal["portrait", "landscape"] = "portrait" + html: str + + +class XlsxSheet(BaseModel): + name: str = Field(max_length=200) + orientation: Literal["portrait", "landscape"] = "portrait" + grid: dict[str, Any] + + +class PdfBody(BaseModel): + title: str = Field(default="인쇄", max_length=200) + sheets: list[PdfSheet] = Field(min_length=1, max_length=MAX_SHEETS) + + +class XlsxBody(BaseModel): + title: str = Field(default="인쇄", max_length=200) + sheets: list[XlsxSheet] = Field(min_length=1, max_length=MAX_SHEETS) + + +def _file(data: bytes, media: str, title: str, ext: str) -> Response: + name = quote(f"{title}.{ext}") + return Response( + content=data, + media_type=media, + headers={"Content-Disposition": f"attachment; filename*=UTF-8''{name}"}, + ) + + +@router.post("/{project_id}/print/table-pdf") +async def table_pdf(project_id: UUID, body: PdfBody) -> Response: + doc = document_html(body.title, [s.model_dump() for s in body.sheets]) + try: + data = await asyncio.to_thread(spawn_pdf, doc) + except PrintError as error: + raise HTTPException(status_code=503, detail=str(error)) from error + return _file(data, "application/pdf", body.title, "pdf") + + +@router.post("/{project_id}/print/table-xlsx") +async def table_xlsx(project_id: UUID, body: XlsxBody) -> Response: + try: + data = await asyncio.to_thread(build_xlsx, [s.model_dump() for s in body.sheets]) + except (KeyError, TypeError, ValueError, IndexError) as error: + raise HTTPException(status_code=400, detail=f"표 꼴이 맞지 않음 — {error}") from error + media = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + return _file(data, media, body.title, "xlsx") diff --git a/B09_Estimation/draft/B09_Draft_Print_Table.ts b/B09_Estimation/draft/B09_Draft_Print_Table.ts new file mode 100644 index 000000000..0aaa5a710 --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Print_Table.ts @@ -0,0 +1,165 @@ +/* ============================================================================= + * B09_Draft_Print_Table.ts + * 인쇄 시트 → 서버 몸(PLAN 302-1 · 초안) — 화면과 같은 표 짜기(DataTableOptions)를 그대로 씀. + * sheetHtml = `renderDataTableHtml`(누름 · 입력 뺀 보기 전용) + 그림(data: 주소) → 서버 표 PDF + * sheetGrid = 머리 줄(묶음 병합) · 값 줄(숫자 = 숫자 · 합계 = 끝 줄) · 열 폭 · 소수 · 레벨 · 세로 병합 → 서버 엑셀 + * 그림 = 엑셀엔 안 넣음(PDF 만) + * ========================================================================== */ + +import { + mergeSpans, + renderDataTableHtml, + SCALE, + type DataTableOptions, +} from "@ui/ui_template_data_table"; +import type { PrintSheet } from "./B09_Draft_Print_Types"; + +export interface GridHead { + r: number; + c: number; + rs: number; + cs: number; + text: string; +} + +export interface SheetGrid { + head_rows: number; + head: GridHead[]; + /** 열 폭 px · 소수 자리(글 열 = null) */ + cols: { w: number; dec: number | null }[]; + rows: (string | number | null)[][]; + bold: boolean[]; + levels: (number | null)[]; + level_col: number | null; + /** 본문 세로 병합(mergeSame) — r = 값 줄 번호 */ + merges: { r: number; c: number; rs: number }[]; + frozen: number; +} + +const ESC_HTML: Record = { "&": "&", "<": "<", ">": ">", '"': """ }; +const escHtml = (s: string): string => s.replace(/[&<>"]/g, (c) => ESC_HTML[c]); + +/** 보기 전용 — 누름 · 입력 · 강조를 뺀 같은 표 */ +const still = (t: DataTableOptions): DataTableOptions => ({ + ...t, + onRowClick: undefined, + onCellClick: undefined, + editable: undefined, + onEdit: undefined, + editOnDouble: false, + editing: null, + focus: null, + fillViewport: false, +}); + +export function sheetHtml(s: PrintSheet): string { + const figs = (s.images ?? []) + .map( + (im) => + `
` + + (im.caption ? `
${escHtml(im.caption)}
` : "") + + `
`, + ) + .join(""); + return renderDataTableHtml(still(s.table)) + figs; +} + +function cellValue(v: unknown): string | number | null { + if (v === null || v === undefined || v === "") return null; + if (typeof v === "number") return Number.isFinite(v) ? v : null; + return String(v); +} + +export function sheetGrid(table: DataTableOptions): SheetGrid { + const t = still(table); + const cols = t.columns; + const depth = Math.max(0, ...cols.map((c) => c.group?.length ?? 0)); + const head: GridHead[] = []; + const same = (a: string[] | undefined, b: string[] | undefined, n: number) => + (b?.length ?? 0) > n && a!.slice(0, n + 1).join("\u0000") === b!.slice(0, n + 1).join("\u0000"); + // 화면 머리(`headHtml`)와 같은 칸 나눔 — 묶음 = 옆으로 합침 · 맨 아래 머리 = 아래로 합침 + for (let lv = 0; lv <= depth; lv++) { + for (let j = 0; j < cols.length; j++) { + const g = cols[j].group?.length ?? 0; + if (g > lv) { + let k = j; + while (k + 1 < cols.length && same(cols[j].group, cols[k + 1].group, lv)) k++; + head.push({ r: lv, c: j, rs: 1, cs: k - j + 1, text: cols[j].group![lv] }); + j = k; + } else if (g === lv) { + head.push({ r: lv, c: j, rs: depth - lv + 1, cs: 1, text: cols[j].label }); + } + } + } + const n = t.rows.length; + const rows = t.rows.map((r) => cols.map((c) => cellValue(r[c.key]))); + const bold = t.rows.map((_r, i) => /\bis-title\b/.test(t.rowClass?.(i) ?? "")); + const levels = t.rows.map((_r, i) => (t.level ? (t.level.of(i) ?? 1) : null)); + if (t.total) { + rows.push(cols.map((c) => cellValue(t.total![c.key]))); + bold.push(true); + levels.push(null); + } + const merges: SheetGrid["merges"] = []; + if (t.mergeSame?.length) + mergeSpans(t, 0, n).forEach((spans, i) => + spans.forEach((rs, c) => rs > 1 && merges.push({ r: i, c, rs })), + ); + const lvCol = t.level ? cols.findIndex((c) => c.key === t.level!.key) : -1; + return { + head_rows: depth + 1, + head, + cols: cols.map((c) => ({ + w: Math.round((c.width ?? (c.kind === "number" ? 84 : 96)) * SCALE), + dec: c.kind === "number" ? (c.decimals ?? 0) : null, + })), + rows, + bold, + levels, + level_col: lvCol >= 0 ? lvCol : null, + merges, + frozen: Math.min(t.frozen ?? 0, cols.length), + }; +} + +/** 서버 몸 — 함수 칸(rowClass · level.of …)은 여기서 펼쳐 글 · 값으로만 보냄 */ +export const pdfBody = (title: string, sheets: PrintSheet[]) => ({ + title, + sheets: sheets.map((s) => ({ + name: s.name, + orientation: s.orientation ?? "portrait", + html: sheetHtml(s), + })), +}); + +export const xlsxBody = (title: string, sheets: PrintSheet[]) => ({ + title, + sheets: sheets.map((s) => ({ + name: s.name, + orientation: s.orientation ?? "portrait", + grid: sheetGrid(s.table), + })), +}); + +/** 그림 주소 → data: 주소(서버 헤드리스는 바깥 요청을 막음 · 로그인 쿠키로 브라우저가 받아 둠) */ +export async function inlineImages(s: PrintSheet): Promise { + if (!s.images?.some((im) => !im.src.startsWith("data:"))) return s; + const images = await Promise.all( + s.images.map(async (im) => { + if (im.src.startsWith("data:")) return im; + try { + const blob = await (await fetch(im.src, { credentials: "include" })).blob(); + const src = await new Promise((done, fail) => { + const reader = new FileReader(); + reader.onload = () => done(String(reader.result)); + reader.onerror = () => fail(reader.error); + reader.readAsDataURL(blob); + }); + return { ...im, src }; + } catch { + return { ...im, src: "" }; + } + }), + ); + return { ...s, images: images.filter((im) => im.src) }; +} diff --git a/B09_Estimation/draft/B09_Draft_Print_Types.ts b/B09_Estimation/draft/B09_Draft_Print_Types.ts new file mode 100644 index 000000000..8d096f5c0 --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Print_Types.ts @@ -0,0 +1,32 @@ +/* ============================================================================= + * B09_Draft_Print_Types.ts + * 인쇄 미리보기(PLAN 302 · 초안) 약속 — 영역 셋이 페이지(`B09_Draft_Print_Page`)에 넘기는 꼴. + * 수량산출 = `B09_Draft_Print_Quantity.collectQuantitySheets` · 원가계산 = `B09_Draft_Print_Cost.collectCostSheets` + * → PrintSheet[](쪽 = 시트 · 차례 그대로) → 서버 표 PDF · 엑셀 + * 도면 = `B09_Draft_Print_Drawings.fetchDrawingList` · `fetchDrawingsPdf`(서버가 만든 PDF 그대로) + * ========================================================================== */ + +import type { DataTableOptions } from "@ui/ui_template_data_table"; + +/** 표 한 쪽(시트) — 화면과 같은 표 짜기 + 쪽 방향 · 붙일 그림 */ +export interface PrintSheet { + id: string; + name: string; + table: DataTableOptions; + orientation?: "portrait" | "landscape"; + images?: { src: string; caption?: string }[]; +} + +/** 수량산출 · 원가계산 영역 — 프로젝트의 쪽 목록(차례 그대로) */ +export type CollectSheets = (projectId: string) => Promise; + +/** 도면 영역 한 장 — id = fetchDrawingsPdf 의 ids 로 돌려줌 */ +export interface DrawingItem { + id: string; + name: string; +} + +export type FetchDrawingList = (projectId: string) => Promise; + +/** ids 없음 = 목록 전부 · 있으면 그 차례대로 */ +export type FetchDrawingsPdf = (projectId: string, ids?: string[]) => Promise; diff --git a/B09_Estimation/draft/B09_Draft_Print_UI.ts b/B09_Estimation/draft/B09_Draft_Print_UI.ts new file mode 100644 index 000000000..b4c1fc357 --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Print_UI.ts @@ -0,0 +1,22 @@ +/* ============================================================================= + * B09_Draft_Print_UI.ts + * 인쇄 미리보기 등록 입구(PLAN 302-1 · 초안) — B09 셸이 `import.meta.glob("./draft/*_UI.ts")` 로 불러올 때 + * 좌측 하단 고정 줄(`DRAFT.actions`)에 [인쇄 미리보기] 단추 → #/b09-print(`…_Print_Route`) + * draft 폴더째 지우면 단추 · 길 · 서버 길이 함께 사라짐 + * ========================================================================== */ + +import type { RoutePath } from "@config/config_frontend"; +import { createButton } from "@ui/ui_template_elements"; +import { navigateTo } from "../../A00_Common/router"; +import { DRAFT } from "../B09_Estimation_UI_Tabs"; +import { ROUTE } from "./B09_Draft_Print_Route"; + +DRAFT.actions.push(() => { + const button = createButton({ + label: "인쇄 미리보기", + variant: "ghost", + onClick: () => navigateTo(ROUTE.path as RoutePath), + }); + button.dataset.b09Print = ""; + return button; +}); diff --git a/requirements.txt b/requirements.txt index 903388fd7..09d89ee34 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,6 +12,7 @@ et_xmlfile==2.0.0 fastapi==0.104.1 bcrypt>=4.1,<5 email-validator>=2.1,<3 +ezdxf==1.4.4 fiona==1.10.1 geopandas==1.1.4 h11==0.16.0 @@ -32,6 +33,7 @@ playwright==1.62.0 psutil>=6.1,<7 pydantic==2.5.0 pydantic_core==2.14.1 +PyMuPDF==1.28.2 PyMySQL==1.2.0 pyparsing==3.3.2 pyproj==3.7.2 diff --git a/resources/tester/fixtures/b08_302_3_print_quantity.json b/resources/tester/fixtures/b08_302_3_print_quantity.json new file mode 100644 index 000000000..3e14de82f --- /dev/null +++ b/resources/tester/fixtures/b08_302_3_print_quantity.json @@ -0,0 +1,2268 @@ +{ + "_from": "검증_sub8 GET 응답(구조물 집계표만 손 견본) — test_302_3_print_quantity", + "/route/structures": { + "status": "success", + "project_id": "1bb0f876-983f-4ca5-9cc7-f59ee1f77e0a", + "revision": 0, + "structures": [] + }, + "/drainage/pipe-points": { + "pipe_points": [ + { + "chainage_m": 85.05, + "source": "spacing", + "options": { + "pipe_diameter_mm": 1000, + "pipe_kind": "파형강관", + "inlet_type": "집수정", + "inlet_basin_length_m": 2, + "inlet_basin_before_m": 1, + "inlet_basin_after_m": 1, + "inlet_basin_height_m": 1.5, + "inlet_revet_form": "돌쌓기(찰)", + "inlet_revet_length_m": 10, + "inlet_revet_before_m": 5, + "inlet_revet_after_m": 5, + "inlet_revet_height_m": 1.5, + "outlet_type": "기슭막이", + "outlet_revet_form": "돌쌓기(찰)", + "outlet_revet_length_m": 10, + "outlet_revet_before_m": 5, + "outlet_revet_after_m": 5, + "outlet_revet_height_m": 1.5, + "inlet_structure": "L" + } + }, + { + "chainage_m": 173.09, + "source": "spacing", + "options": { + "pipe_kind": "파형강관", + "pipe_diameter_mm": "1000", + "ford_width_m": 10, + "wing_in": "있음", + "wing_in_height_m": 1, + "wing_in_length_m": 2, + "wing_in_angle_deg": 45, + "wing_out": "있음", + "wing_out_height_m": 1, + "wing_out_length_m": 2, + "wing_out_angle_deg": 45 + } + }, + { + "chainage_m": 258.12, + "source": "spacing", + "options": { + "pipe_diameter_mm": 800, + "pipe_kind": "파형강관", + "inlet_type": "집수정", + "inlet_basin_length_m": 2, + "inlet_basin_before_m": 1, + "inlet_basin_after_m": 1, + "inlet_basin_height_m": 1.5, + "inlet_revet_form": "돌쌓기(찰)", + "inlet_revet_length_m": 10, + "inlet_revet_before_m": 5, + "inlet_revet_after_m": 5, + "inlet_revet_height_m": 1.5, + "outlet_type": "기슭막이", + "outlet_revet_form": "돌쌓기(찰)", + "outlet_revet_length_m": 10, + "outlet_revet_before_m": 5, + "outlet_revet_after_m": 5, + "outlet_revet_height_m": 1.5, + "inlet_structure": "L" + } + }, + { + "chainage_m": 352.14, + "source": "stream", + "options": { + "pipe_kind": "파형강관", + "pipe_diameter_mm": "1000", + "ford_width_m": 10, + "wing_in": "있음", + "wing_in_height_m": 1, + "wing_in_length_m": 2, + "wing_in_angle_deg": 45, + "wing_out": "있음", + "wing_out_height_m": 1, + "wing_out_length_m": 2, + "wing_out_angle_deg": 45 + } + }, + { + "chainage_m": 440.24, + "source": "spacing", + "options": { + "pipe_diameter_mm": 800, + "pipe_kind": "파형강관", + "inlet_type": "집수정", + "inlet_basin_length_m": 2, + "inlet_basin_before_m": 1, + "inlet_basin_after_m": 1, + "inlet_basin_height_m": 1.5, + "inlet_revet_form": "돌쌓기(찰)", + "inlet_revet_length_m": 10, + "inlet_revet_before_m": 5, + "inlet_revet_after_m": 5, + "inlet_revet_height_m": 1.5, + "outlet_type": "기슭막이", + "outlet_revet_form": "돌쌓기(찰)", + "outlet_revet_length_m": 10, + "outlet_revet_before_m": 5, + "outlet_revet_after_m": 5, + "outlet_revet_height_m": 1.5, + "inlet_structure": "L" + } + }, + { + "chainage_m": 533.98, + "source": "spacing", + "options": { + "pipe_diameter_mm": 800, + "pipe_kind": "파형강관", + "inlet_type": "집수정", + "inlet_basin_length_m": 2, + "inlet_basin_before_m": 1, + "inlet_basin_after_m": 1, + "inlet_basin_height_m": 1.5, + "inlet_revet_form": "돌쌓기(찰)", + "inlet_revet_length_m": 10, + "inlet_revet_before_m": 5, + "inlet_revet_after_m": 5, + "inlet_revet_height_m": 1.5, + "outlet_type": "기슭막이", + "outlet_revet_form": "돌쌓기(찰)", + "outlet_revet_length_m": 10, + "outlet_revet_before_m": 5, + "outlet_revet_after_m": 5, + "outlet_revet_height_m": 1.5, + "inlet_structure": "L" + } + }, + { + "chainage_m": 619.71, + "source": "spacing", + "options": { + "pipe_diameter_mm": 1500, + "pipe_kind": "파형강관", + "inlet_type": "집수정", + "inlet_basin_length_m": 2, + "inlet_basin_before_m": 1, + "inlet_basin_after_m": 1, + "inlet_basin_height_m": 1.5, + "inlet_revet_form": "돌쌓기(찰)", + "inlet_revet_length_m": 10, + "inlet_revet_before_m": 5, + "inlet_revet_after_m": 5, + "inlet_revet_height_m": 1.5, + "outlet_type": "기슭막이", + "outlet_revet_form": "돌쌓기(찰)", + "outlet_revet_length_m": 10, + "outlet_revet_before_m": 5, + "outlet_revet_after_m": 5, + "outlet_revet_height_m": 1.5, + "inlet_structure": "L" + } + }, + { + "chainage_m": 720.44, + "source": "spacing", + "options": { + "pipe_diameter_mm": 800, + "pipe_kind": "파형강관", + "inlet_type": "집수정", + "inlet_basin_length_m": 2, + "inlet_basin_before_m": 1, + "inlet_basin_after_m": 1, + "inlet_basin_height_m": 1.5, + "inlet_revet_form": "돌쌓기(찰)", + "inlet_revet_length_m": 10, + "inlet_revet_before_m": 5, + "inlet_revet_after_m": 5, + "inlet_revet_height_m": 1.5, + "outlet_type": "기슭막이", + "outlet_revet_form": "돌쌓기(찰)", + "outlet_revet_length_m": 10, + "outlet_revet_before_m": 5, + "outlet_revet_after_m": 5, + "outlet_revet_height_m": 1.5, + "inlet_structure": "L" + } + }, + { + "chainage_m": 804.18, + "source": "spacing", + "options": { + "pipe_diameter_mm": 800, + "pipe_kind": "파형강관", + "inlet_type": "집수정", + "inlet_basin_length_m": 2, + "inlet_basin_before_m": 1, + "inlet_basin_after_m": 1, + "inlet_basin_height_m": 1.5, + "inlet_revet_form": "돌쌓기(찰)", + "inlet_revet_length_m": 10, + "inlet_revet_before_m": 5, + "inlet_revet_after_m": 5, + "inlet_revet_height_m": 1.5, + "outlet_type": "기슭막이", + "outlet_revet_form": "돌쌓기(찰)", + "outlet_revet_length_m": 10, + "outlet_revet_before_m": 5, + "outlet_revet_after_m": 5, + "outlet_revet_height_m": 1.5, + "inlet_structure": "L" + } + }, + { + "chainage_m": 899.91, + "source": "spacing", + "options": { + "pipe_diameter_mm": 800, + "pipe_kind": "파형강관", + "inlet_type": "집수정", + "inlet_basin_length_m": 2, + "inlet_basin_before_m": 1, + "inlet_basin_after_m": 1, + "inlet_basin_height_m": 1.5, + "inlet_revet_form": "돌쌓기(찰)", + "inlet_revet_length_m": 10, + "inlet_revet_before_m": 5, + "inlet_revet_after_m": 5, + "inlet_revet_height_m": 1.5, + "outlet_type": "기슭막이", + "outlet_revet_form": "돌쌓기(찰)", + "outlet_revet_length_m": 10, + "outlet_revet_before_m": 5, + "outlet_revet_after_m": 5, + "outlet_revet_height_m": 1.5, + "inlet_structure": "L" + } + }, + { + "chainage_m": 982.64, + "source": "spacing", + "options": { + "pipe_diameter_mm": 1500, + "pipe_kind": "파형강관", + "inlet_type": "집수정", + "inlet_basin_length_m": 2, + "inlet_basin_before_m": 1, + "inlet_basin_after_m": 1, + "inlet_basin_height_m": 1.5, + "inlet_revet_form": "돌쌓기(찰)", + "inlet_revet_length_m": 10, + "inlet_revet_before_m": 5, + "inlet_revet_after_m": 5, + "inlet_revet_height_m": 1.5, + "outlet_type": "기슭막이", + "outlet_revet_form": "돌쌓기(찰)", + "outlet_revet_length_m": 10, + "outlet_revet_before_m": 5, + "outlet_revet_after_m": 5, + "outlet_revet_height_m": 1.5, + "inlet_structure": "L" + } + } + ] + }, + "/route/latest": { + "route_params": { + "station_interval_m": 20 + } + }, + "/quantity/summary": { + "columns": [ + { + "id": "pipe:len|800", + "group": [ + "관 공", + "Φ800m/m", + "관매설" + ], + "label": "m", + "combos": [] + }, + { + "id": "pipe:cp|800", + "group": [ + "관 공", + "Φ800m/m", + "커플링" + ], + "label": "ea", + "combos": [] + } + ], + "rows": [ + { + "chainage_m": 20.0, + "values": { + "pipe:len|800": 8, + "pipe:cp|800": 1 + } + }, + { + "chainage_m": 60.0, + "values": { + "pipe:len|800": 12.5, + "pipe:cp|800": 2 + } + } + ], + "total": { + "pipe:len|800": 20.5, + "pipe:cp|800": 3 + }, + "sources": [], + "notes": [ + "견본 알림" + ], + "one_length_m": null, + "types": [ + "pipe" + ], + "pipes": [], + "linked": {}, + "unlinked": [ + { + "id": "pipe@20.000", + "type_id": "pipe", + "name": "횡단배수관", + "facility": "pipe", + "chainage_m": 20.0, + "slots": [ + { + "copy": "pipe@20.000", + "group": null, + "item": null, + "linked": false + } + ] + }, + { + "id": "pipe@100.000", + "type_id": "pipe", + "name": "횡단배수관", + "facility": "pipe", + "chainage_m": 100.0, + "slots": [ + { + "copy": "pipe@100.000", + "group": null, + "item": null, + "linked": false + } + ] + } + ], + "facilities": [ + { + "id": "pipe@20.000", + "type_id": "pipe", + "name": "횡단배수관", + "facility": "pipe", + "chainage_m": 20.0, + "slots": [ + { + "copy": "pipe@20.000", + "group": null, + "item": null, + "linked": false + } + ] + }, + { + "id": "pipe@60.000", + "type_id": "pipe", + "name": "횡단배수관", + "facility": "pipe", + "chainage_m": 60.0, + "slots": [ + { + "copy": "pipe@60.000", + "group": null, + "item": null, + "linked": false + } + ] + }, + { + "id": "pipe@100.000", + "type_id": "pipe", + "name": "횡단배수관", + "facility": "pipe", + "chainage_m": 100.0, + "slots": [ + { + "copy": "pipe@100.000", + "group": null, + "item": null, + "linked": false + } + ] + } + ] + }, + "/quantity/materials": { + "source": { + "columns": [], + "total": {}, + "snapshot": { + "materials": [], + "units": {} + } + }, + "overrides": { + "units": {}, + "mix": {}, + "surcharge": {} + }, + "surcharge_pct": { + "sand": 12, + "gravel": 5, + "cement": 3 + }, + "notes": [ + "조합 사본 없는 시설 11곳 — 노랑 램프 줄을 눌러 조합 연결" + ], + "sheets": { + "quantity": { + "materials": [ + { + "key": "concrete_machine", + "name": "콘크리트", + "spec": "기계", + "unit": "㎥" + }, + { + "key": "mortar_1_3", + "name": "모르터", + "spec": "1:3", + "unit": "㎥" + } + ], + "rows": [], + "total": {}, + "unmatched": [] + }, + "concrete": { + "rows": [ + { + "key": "c25", + "name": "콘크리트", + "spec": "Φ25mm", + "qty": null, + "per": { + "cement": 346.0, + "sand": 0.487, + "gravel": 0.562 + }, + "amounts": { + "cement": null, + "sand": null, + "gravel": null + } + }, + { + "key": "c40", + "name": "콘크리트", + "spec": "Φ40mm", + "qty": null, + "per": { + "cement": 323.0, + "sand": 0.456, + "gravel": 0.612 + }, + "amounts": { + "cement": null, + "sand": null, + "gravel": null + } + }, + { + "key": "c40_plain", + "name": "콘크리트", + "spec": "Φ40mm(무근)", + "qty": null, + "per": { + "cement": null, + "sand": null, + "gravel": null + }, + "amounts": { + "cement": null, + "sand": null, + "gravel": null + } + }, + { + "key": "m12", + "name": "모르터", + "spec": "1:2", + "qty": null, + "per": { + "cement": 680.0, + "sand": 0.98, + "gravel": null + }, + "amounts": { + "cement": null, + "sand": null, + "gravel": null + } + }, + { + "key": "m13", + "name": "모르터", + "spec": "1:3", + "qty": null, + "per": { + "cement": 510.0, + "sand": 1.1, + "gravel": null + }, + "amounts": { + "cement": null, + "sand": null, + "gravel": null + } + } + ], + "total": {} + }, + "material": { + "rows": [ + { + "key": "sand", + "group": "골재", + "spec": "모래", + "unit": "㎥", + "qty": null, + "surcharge": 12.0, + "amount": null, + "places": 0, + "rounding": "up", + "panel": true + }, + { + "key": "gravel", + "group": "골재", + "spec": "자갈", + "unit": "㎥", + "qty": null, + "surcharge": 5.0, + "amount": null, + "places": 0, + "rounding": "up", + "panel": true + }, + { + "key": "rubble", + "group": "골재", + "spec": "막자갈", + "unit": "㎥", + "qty": null, + "surcharge": 4.0, + "amount": null, + "places": 0, + "rounding": "up", + "panel": false + }, + { + "key": "mixed_aggregate", + "group": "골재", + "spec": "혼합석", + "unit": "㎥", + "qty": null, + "surcharge": 4.0, + "amount": null, + "places": 0, + "rounding": "up", + "panel": false + }, + { + "key": "remicon_25_24_15", + "group": "레미콘", + "spec": "25-24-15", + "unit": "㎥", + "qty": null, + "surcharge": 1.0, + "amount": null, + "places": 2, + "rounding": "round", + "panel": false + }, + { + "key": "remicon_25_21_80", + "group": "레미콘", + "spec": "25-21-80", + "unit": "㎥", + "qty": null, + "surcharge": 2.0, + "amount": null, + "places": 2, + "rounding": "round", + "panel": false + }, + { + "key": "remicon_25_18_80", + "group": "레미콘", + "spec": "25-18-80", + "unit": "㎥", + "qty": null, + "surcharge": 2.0, + "amount": null, + "places": 2, + "rounding": "round", + "panel": false + }, + { + "key": "rebar_d13", + "group": "이형철근", + "spec": "D13mm", + "unit": "ton", + "qty": null, + "surcharge": 3.0, + "amount": null, + "places": 3, + "rounding": "round", + "panel": false + }, + { + "key": "rebar_d16", + "group": "이형철근", + "spec": "D16mm", + "unit": "ton", + "qty": null, + "surcharge": 3.0, + "amount": null, + "places": 3, + "rounding": "round", + "panel": false + }, + { + "key": "rebar_d19", + "group": "이형철근", + "spec": "D19mm", + "unit": "ton", + "qty": null, + "surcharge": 3.0, + "amount": null, + "places": 3, + "rounding": "round", + "panel": false + }, + { + "key": "rebar_d22", + "group": "이형철근", + "spec": "D22mm", + "unit": "ton", + "qty": null, + "surcharge": 3.0, + "amount": null, + "places": 3, + "rounding": "round", + "panel": false + }, + { + "key": "rebar_d25", + "group": "이형철근", + "spec": "D25mm", + "unit": "ton", + "qty": null, + "surcharge": 3.0, + "amount": null, + "places": 3, + "rounding": "round", + "panel": false + }, + { + "key": "wire_mesh", + "group": "와이어메쉬", + "spec": "#6, 100×100", + "unit": "㎡", + "qty": null, + "surcharge": null, + "amount": null, + "places": 3, + "rounding": "round", + "panel": false + }, + { + "key": "cement", + "group": "시멘트", + "spec": "40kg/대", + "unit": "대", + "qty": null, + "surcharge": 3.0, + "amount": null, + "places": 0, + "rounding": "round", + "panel": true + }, + { + "key": "sod", + "group": "떼", + "spec": "", + "unit": "㎡", + "qty": null, + "surcharge": 10.0, + "amount": null, + "places": 0, + "rounding": "up", + "panel": false + }, + { + "key": "stone_buy_35", + "group": "깬돌", + "spec": "L3=35cm", + "unit": "ton", + "qty": null, + "surcharge": null, + "amount": null, + "places": 0, + "rounding": "up", + "panel": false + }, + { + "key": "stone_buy_45", + "group": "깬돌", + "spec": "L3=45cm", + "unit": "ton", + "qty": null, + "surcharge": null, + "amount": null, + "places": 0, + "rounding": "up", + "panel": false + }, + { + "key": "revet_block", + "group": "호안블럭", + "spec": "", + "unit": "㎡", + "qty": null, + "surcharge": null, + "amount": null, + "places": 0, + "rounding": "up", + "panel": false + }, + { + "key": "flume_400c", + "group": "수로관", + "spec": "400C", + "unit": "본", + "qty": null, + "surcharge": null, + "amount": null, + "places": 0, + "rounding": "up", + "panel": false + }, + { + "key": "steel_grating", + "group": "스틸그레이팅", + "spec": "", + "unit": "EA", + "qty": null, + "surcharge": null, + "amount": null, + "places": 0, + "rounding": "up", + "panel": false + }, + { + "key": "picked_deduct", + "group": "채집석 공제", + "spec": "돌(채집) · 고임돌 · 막자갈", + "unit": "㎥", + "qty": null, + "surcharge": null, + "amount": null, + "places": 6, + "rounding": "round", + "panel": false + } + ] + } + } + }, + "/quantity/earthwork": { + "method": "average_end_area", + "conversion_factors": { + "soil": { + "loose": 1.25, + "compacted": 0.9 + }, + "ripping_rock": { + "loose": 1.35, + "compacted": 1.1649999999999998 + }, + "blasting_rock": { + "loose": 1.6, + "compacted": 1.1649999999999998 + } + }, + "rows": [ + { + "chainage_m": 0.0, + "distance_m": 0.0, + "cut_soil_area_m2": 0.2467, + "cut_soil_volume_m3": 0.0, + "cut_soil_adjusted_m3": 0.0, + "cut_rock_area_m2": 0.0, + "cut_rock_volume_m3": 0.0, + "cut_rock_adjusted_m3": 0.0, + "ditch_soil_area_m2": 0.1485, + "ditch_soil_volume_m3": 0.0, + "ditch_soil_adjusted_m3": 0.0, + "ditch_rock_area_m2": 0.0, + "ditch_rock_volume_m3": 0.0, + "ditch_rock_adjusted_m3": 0.0, + "adjusted_total_m3": 0.0, + "fill_area_m2": 0.0994, + "fill_volume_m3": 0.0, + "diverted_m3": 0.0, + "balance_m3": 0.0, + "cumulative_m3": 0.0, + "ditch_split_basis": "rock_boundary", + "cut_rock_kind": "ripping_rock", + "notes": [], + "faces": { + "grading_fill": { + "length_m": 0.3133420016531395, + "area_m2": null + }, + "grading_cut": { + "length_m": 0, + "area_m2": null + }, + "tree_removal_fill": { + "length_m": 0.3133420016531395, + "area_m2": null + }, + "tree_removal_cut": { + "length_m": 0, + "area_m2": null + }, + "grubbing": { + "length_m": null, + "area_m2": null + }, + "road_surface": { + "length_m": 4.0, + "area_m2": null + }, + "benching": { + "length_m": 0.2585, + "area_m2": null + } + }, + "cut_soft_rock_area_m2": 0.0, + "cut_hard_rock_area_m2": 0.0, + "cut_soft_rock_volume_m3": 0.0, + "cut_hard_rock_volume_m3": 0.0, + "cut_soft_rock_adjusted_m3": 0.0, + "cut_hard_rock_adjusted_m3": 0.0, + "ditch_soft_rock_area_m2": 0.0, + "ditch_hard_rock_area_m2": 0.0, + "ditch_soft_rock_volume_m3": 0.0, + "ditch_hard_rock_volume_m3": 0.0, + "ditch_soft_rock_adjusted_m3": 0.0, + "ditch_hard_rock_adjusted_m3": 0.0 + }, + { + "chainage_m": 20.0, + "distance_m": 20.0, + "cut_soil_area_m2": 1.7856, + "cut_soil_volume_m3": 20.323, + "cut_soil_adjusted_m3": 18.2907, + "cut_rock_area_m2": 1.5424, + "cut_rock_volume_m3": 15.424, + "cut_rock_adjusted_m3": 17.968959999999996, + "ditch_soil_area_m2": 0.0, + "ditch_soil_volume_m3": 1.4849999999999999, + "ditch_soil_adjusted_m3": 1.3365, + "ditch_rock_area_m2": 0.1485, + "ditch_rock_volume_m3": 1.4849999999999999, + "ditch_rock_adjusted_m3": 1.7300249999999995, + "adjusted_total_m3": 36.25966, + "fill_area_m2": 6.6947, + "fill_volume_m3": 67.941, + "diverted_m3": 36.25966, + "balance_m3": -31.681340000000006, + "cumulative_m3": -31.681340000000006, + "ditch_split_basis": "rock_boundary", + "cut_rock_kind": "ripping_rock", + "notes": [], + "faces": { + "grading_fill": { + "length_m": 8.905743700766662, + "area_m2": 92.19085702419802 + }, + "grading_cut": { + "length_m": 4.23533123091777, + "area_m2": 42.353312309177696 + }, + "tree_removal_fill": { + "length_m": 8.905743700766662, + "area_m2": 92.19085702419802 + }, + "tree_removal_cut": { + "length_m": 4.23533123091777, + "area_m2": 42.353312309177696 + }, + "grubbing": { + "length_m": null, + "area_m2": null + }, + "road_surface": { + "length_m": 4.0, + "area_m2": 80.0 + }, + "benching": { + "length_m": 10.6474, + "area_m2": 109.059 + } + }, + "cut_soft_rock_area_m2": 1.38816, + "cut_hard_rock_area_m2": 0.15423999999999993, + "cut_soft_rock_volume_m3": 13.881599999999999, + "cut_hard_rock_volume_m3": 1.5424000000000007, + "cut_soft_rock_adjusted_m3": 16.172063999999995, + "cut_hard_rock_adjusted_m3": 1.7968960000000003, + "ditch_soft_rock_area_m2": 0.13365, + "ditch_hard_rock_area_m2": 0.014850000000000002, + "ditch_soft_rock_volume_m3": 1.3364999999999998, + "ditch_hard_rock_volume_m3": 0.14850000000000008, + "ditch_soft_rock_adjusted_m3": 1.5570224999999995, + "ditch_hard_rock_adjusted_m3": 0.17300249999999995 + }, + { + "chainage_m": 40.0, + "distance_m": 20.0, + "cut_soil_area_m2": 2.5631, + "cut_soil_volume_m3": 43.487, + "cut_soil_adjusted_m3": 39.1383, + "cut_rock_area_m2": 2.1052, + "cut_rock_volume_m3": 36.476, + "cut_rock_adjusted_m3": 42.49453999999999, + "ditch_soil_area_m2": 0.0, + "ditch_soil_volume_m3": 0.0, + "ditch_soil_adjusted_m3": 0.0, + "ditch_rock_area_m2": 0.1485, + "ditch_rock_volume_m3": 2.9699999999999998, + "ditch_rock_adjusted_m3": 3.460049999999999, + "adjusted_total_m3": 81.63283999999999, + "fill_area_m2": 3.1265, + "fill_volume_m3": 98.21200000000002, + "diverted_m3": 81.63283999999999, + "balance_m3": -16.57916000000003, + "cumulative_m3": -48.260500000000036, + "ditch_split_basis": "rock_boundary", + "cut_rock_kind": "ripping_rock", + "notes": [], + "faces": { + "grading_fill": { + "length_m": 5.531131936823837, + "area_m2": 144.368756375905 + }, + "grading_cut": { + "length_m": 7.171260215611091, + "area_m2": 114.06591446528861 + }, + "tree_removal_fill": { + "length_m": 5.531131936823837, + "area_m2": 144.368756375905 + }, + "tree_removal_cut": { + "length_m": 7.171260215611091, + "area_m2": 114.06591446528861 + }, + "grubbing": { + "length_m": null, + "area_m2": null + }, + "road_surface": { + "length_m": 4.0, + "area_m2": 80.0 + }, + "benching": { + "length_m": 6.2807, + "area_m2": 169.281 + } + }, + "cut_soft_rock_area_m2": 1.89468, + "cut_hard_rock_area_m2": 0.21052000000000004, + "cut_soft_rock_volume_m3": 32.8284, + "cut_hard_rock_volume_m3": 3.647599999999997, + "cut_soft_rock_adjusted_m3": 38.24508599999999, + "cut_hard_rock_adjusted_m3": 4.249454, + "ditch_soft_rock_area_m2": 0.13365, + "ditch_hard_rock_area_m2": 0.014850000000000002, + "ditch_soft_rock_volume_m3": 2.6729999999999996, + "ditch_hard_rock_volume_m3": 0.29700000000000015, + "ditch_soft_rock_adjusted_m3": 3.114044999999999, + "ditch_hard_rock_adjusted_m3": 0.3460049999999999 + }, + { + "chainage_m": 60.0, + "distance_m": 20.0, + "cut_soil_area_m2": 1.9285, + "cut_soil_volume_m3": 44.916, + "cut_soil_adjusted_m3": 40.4244, + "cut_rock_area_m2": 2.5755, + "cut_rock_volume_m3": 46.807, + "cut_rock_adjusted_m3": 54.53015499999999, + "ditch_soil_area_m2": 0.0, + "ditch_soil_volume_m3": 0.0, + "ditch_soil_adjusted_m3": 0.0, + "ditch_rock_area_m2": 0.1485, + "ditch_rock_volume_m3": 2.9699999999999998, + "ditch_rock_adjusted_m3": 3.460049999999999, + "adjusted_total_m3": 94.954555, + "fill_area_m2": 6.1454, + "fill_volume_m3": 92.71900000000001, + "diverted_m3": 92.71900000000001, + "balance_m3": 2.235554999999991, + "cumulative_m3": -46.024945000000045, + "ditch_split_basis": "rock_boundary", + "cut_rock_kind": "ripping_rock", + "notes": [], + "faces": { + "grading_fill": { + "length_m": 7.089116136811305, + "area_m2": 126.20248073635142 + }, + "grading_cut": { + "length_m": 5.436580202518828, + "area_m2": 126.07840418129918 + }, + "tree_removal_fill": { + "length_m": 7.089116136811305, + "area_m2": 126.20248073635142 + }, + "tree_removal_cut": { + "length_m": 5.436580202518828, + "area_m2": 126.07840418129918 + }, + "grubbing": { + "length_m": null, + "area_m2": null + }, + "road_surface": { + "length_m": 4.0, + "area_m2": 80.0 + }, + "benching": { + "length_m": 7.421, + "area_m2": 137.017 + } + }, + "cut_soft_rock_area_m2": 2.3179499999999997, + "cut_hard_rock_area_m2": 0.25755000000000017, + "cut_soft_rock_volume_m3": 42.1263, + "cut_hard_rock_volume_m3": 4.680700000000002, + "cut_soft_rock_adjusted_m3": 49.077139499999994, + "cut_hard_rock_adjusted_m3": 5.453015499999999, + "ditch_soft_rock_area_m2": 0.13365, + "ditch_hard_rock_area_m2": 0.014850000000000002, + "ditch_soft_rock_volume_m3": 2.6729999999999996, + "ditch_hard_rock_volume_m3": 0.29700000000000015, + "ditch_soft_rock_adjusted_m3": 3.114044999999999, + "ditch_hard_rock_adjusted_m3": 0.3460049999999999 + }, + { + "chainage_m": 80.0, + "distance_m": 20.0, + "cut_soil_area_m2": 3.3074, + "cut_soil_volume_m3": 52.359, + "cut_soil_adjusted_m3": 47.1231, + "cut_rock_area_m2": 8.608, + "cut_rock_volume_m3": 111.83500000000001, + "cut_rock_adjusted_m3": 130.28777499999998, + "ditch_soil_area_m2": 0.0, + "ditch_soil_volume_m3": 0.0, + "ditch_soil_adjusted_m3": 0.0, + "ditch_rock_area_m2": 0.1485, + "ditch_rock_volume_m3": 2.9699999999999998, + "ditch_rock_adjusted_m3": 3.460049999999999, + "adjusted_total_m3": 177.41087499999998, + "fill_area_m2": 9.5931, + "fill_volume_m3": 157.385, + "diverted_m3": 157.385, + "balance_m3": 20.025874999999985, + "cumulative_m3": -25.99907000000006, + "ditch_split_basis": "rock_boundary", + "cut_rock_kind": "ripping_rock", + "notes": [], + "faces": { + "grading_fill": { + "length_m": 10.41368758963897, + "area_m2": 175.02803726450276 + }, + "grading_cut": { + "length_m": 6.176411853142405, + "area_m2": 116.12992055661232 + }, + "tree_removal_fill": { + "length_m": 10.41368758963897, + "area_m2": 175.02803726450276 + }, + "tree_removal_cut": { + "length_m": 6.176411853142405, + "area_m2": 116.12992055661232 + }, + "grubbing": { + "length_m": null, + "area_m2": null + }, + "road_surface": { + "length_m": 6.0, + "area_m2": 100.0 + }, + "benching": { + "length_m": 11.036, + "area_m2": 184.57 + } + }, + "cut_soft_rock_area_m2": 7.7472, + "cut_hard_rock_area_m2": 0.8608000000000002, + "cut_soft_rock_volume_m3": 100.65150000000001, + "cut_hard_rock_volume_m3": 11.183499999999995, + "cut_soft_rock_adjusted_m3": 117.25899749999999, + "cut_hard_rock_adjusted_m3": 13.02877749999999, + "ditch_soft_rock_area_m2": 0.13365, + "ditch_hard_rock_area_m2": 0.014850000000000002, + "ditch_soft_rock_volume_m3": 2.6729999999999996, + "ditch_hard_rock_volume_m3": 0.29700000000000015, + "ditch_soft_rock_adjusted_m3": 3.114044999999999, + "ditch_hard_rock_adjusted_m3": 0.3460049999999999 + }, + { + "chainage_m": 85.052, + "distance_m": 5.052000000000007, + "cut_soil_area_m2": 2.3174, + "cut_soil_volume_m3": 14.20824480000002, + "cut_soil_adjusted_m3": 12.787420320000018, + "cut_rock_area_m2": 11.2426, + "cut_rock_volume_m3": 50.14261560000006, + "cut_rock_adjusted_m3": 58.416147174000066, + "ditch_soil_area_m2": 0.0, + "ditch_soil_volume_m3": 0.0, + "ditch_soil_adjusted_m3": 0.0, + "ditch_rock_area_m2": 0.1485, + "ditch_rock_volume_m3": 0.7502220000000009, + "ditch_rock_adjusted_m3": 0.874008630000001, + "adjusted_total_m3": 71.20356749400008, + "fill_area_m2": 0.711, + "fill_volume_m3": 26.028156600000035, + "diverted_m3": 26.028156600000035, + "balance_m3": 45.17541089400005, + "cumulative_m3": 19.176340893999992, + "ditch_split_basis": "rock_boundary", + "cut_rock_kind": "ripping_rock", + "notes": [], + "faces": { + "grading_fill": { + "length_m": 0.6508114627140598, + "area_m2": 27.94892460624379 + }, + "grading_cut": { + "length_m": 10.577233048615332, + "area_m2": 42.3197070218401 + }, + "tree_removal_fill": { + "length_m": 0.6508114627140598, + "area_m2": 27.94892460624379 + }, + "tree_removal_cut": { + "length_m": 10.577233048615332, + "area_m2": 42.3197070218401 + }, + "grubbing": { + "length_m": null, + "area_m2": null + }, + "road_surface": { + "length_m": 6.0, + "area_m2": 30.31200000000004 + }, + "benching": { + "length_m": 1.7865, + "area_m2": 32.38963500000004 + } + }, + "cut_soft_rock_area_m2": 10.11834, + "cut_hard_rock_area_m2": 1.1242599999999996, + "cut_soft_rock_volume_m3": 45.12835404000006, + "cut_hard_rock_volume_m3": 5.014261560000001, + "cut_soft_rock_adjusted_m3": 52.57453245660006, + "cut_hard_rock_adjusted_m3": 5.841614717400006, + "ditch_soft_rock_area_m2": 0.13365, + "ditch_hard_rock_area_m2": 0.014850000000000002, + "ditch_soft_rock_volume_m3": 0.6751998000000009, + "ditch_hard_rock_volume_m3": 0.07502220000000004, + "ditch_soft_rock_adjusted_m3": 0.7866077670000009, + "ditch_hard_rock_adjusted_m3": 0.08740086300000005 + }, + { + "chainage_m": 100.0, + "distance_m": 14.947999999999993, + "cut_soil_area_m2": 1.9296, + "cut_soil_volume_m3": 31.742077999999985, + "cut_soil_adjusted_m3": 28.567870199999987, + "cut_rock_area_m2": 3.8204, + "cut_rock_volume_m3": 112.58086199999994, + "cut_rock_adjusted_m3": 131.15670422999992, + "ditch_soil_area_m2": 0.0, + "ditch_soil_volume_m3": 0.0, + "ditch_soil_adjusted_m3": 0.0, + "ditch_rock_area_m2": 0.1485, + "ditch_rock_volume_m3": 2.219777999999999, + "ditch_rock_adjusted_m3": 2.5860413699999985, + "adjusted_total_m3": 159.7245744299999, + "fill_area_m2": 6.678, + "fill_volume_m3": 55.22538599999998, + "diverted_m3": 55.22538599999998, + "balance_m3": 104.49918842999992, + "cumulative_m3": 123.67552932399991, + "ditch_split_basis": "rock_boundary", + "cut_rock_kind": "ripping_rock", + "notes": [], + "faces": { + "grading_fill": { + "length_m": 7.473138706027457, + "area_m2": 60.718403561174064 + }, + "grading_cut": { + "length_m": 3.6186822406822006, + "area_m2": 106.10027087220972 + }, + "tree_removal_fill": { + "length_m": 7.473138706027457, + "area_m2": 60.718403561174064 + }, + "tree_removal_cut": { + "length_m": 3.6186822406822006, + "area_m2": 106.10027087220972 + }, + "grubbing": { + "length_m": null, + "area_m2": null + }, + "road_surface": { + "length_m": 4.0, + "area_m2": 74.73999999999997 + }, + "benching": { + "length_m": 7.3335, + "area_m2": 68.16287999999996 + } + }, + "cut_soft_rock_area_m2": 3.4383599999999994, + "cut_hard_rock_area_m2": 0.3820400000000004, + "cut_soft_rock_volume_m3": 101.32277579999995, + "cut_hard_rock_volume_m3": 11.258086199999994, + "cut_soft_rock_adjusted_m3": 118.04103380699993, + "cut_hard_rock_adjusted_m3": 13.115670422999983, + "ditch_soft_rock_area_m2": 0.13365, + "ditch_hard_rock_area_m2": 0.014850000000000002, + "ditch_soft_rock_volume_m3": 1.997800199999999, + "ditch_hard_rock_volume_m3": 0.2219777999999999, + "ditch_soft_rock_adjusted_m3": 2.3274372329999986, + "ditch_hard_rock_adjusted_m3": 0.25860413699999985 + }, + { + "chainage_m": 120.0, + "distance_m": 20.0, + "cut_soil_area_m2": 1.1459, + "cut_soil_volume_m3": 30.755, + "cut_soil_adjusted_m3": 27.6795, + "cut_rock_area_m2": 0.5294, + "cut_rock_volume_m3": 43.498000000000005, + "cut_rock_adjusted_m3": 50.675169999999994, + "ditch_soil_area_m2": 0.0, + "ditch_soil_volume_m3": 0.0, + "ditch_soil_adjusted_m3": 0.0, + "ditch_rock_area_m2": 0.1485, + "ditch_rock_volume_m3": 2.9699999999999998, + "ditch_rock_adjusted_m3": 3.460049999999999, + "adjusted_total_m3": 78.35467, + "fill_area_m2": 1.7035, + "fill_volume_m3": 83.815, + "diverted_m3": 78.35467, + "balance_m3": -5.460329999999999, + "cumulative_m3": 118.21519932399991, + "ditch_split_basis": "rock_boundary", + "cut_rock_kind": "ripping_rock", + "notes": [], + "faces": { + "grading_fill": { + "length_m": 4.7441020248562396, + "area_m2": 122.17240730883695 + }, + "grading_cut": { + "length_m": 2.184396822180933, + "area_m2": 58.03079062863134 + }, + "tree_removal_fill": { + "length_m": 4.7441020248562396, + "area_m2": 122.17240730883695 + }, + "tree_removal_cut": { + "length_m": 2.184396822180933, + "area_m2": 58.03079062863134 + }, + "grubbing": { + "length_m": null, + "area_m2": null + }, + "road_surface": { + "length_m": 4.0, + "area_m2": 80.0 + }, + "benching": { + "length_m": 5.5333, + "area_m2": 128.668 + } + }, + "cut_soft_rock_area_m2": 0.47646, + "cut_hard_rock_area_m2": 0.05293999999999999, + "cut_soft_rock_volume_m3": 39.1482, + "cut_hard_rock_volume_m3": 4.349800000000002, + "cut_soft_rock_adjusted_m3": 45.60765299999999, + "cut_hard_rock_adjusted_m3": 5.067517000000002, + "ditch_soft_rock_area_m2": 0.13365, + "ditch_hard_rock_area_m2": 0.014850000000000002, + "ditch_soft_rock_volume_m3": 2.6729999999999996, + "ditch_hard_rock_volume_m3": 0.29700000000000015, + "ditch_soft_rock_adjusted_m3": 3.114044999999999, + "ditch_hard_rock_adjusted_m3": 0.3460049999999999 + } + ], + "totals": { + "distance_m": 1078.01, + "cut_soil_volume_m3": 2500.31308435, + "cut_soil_adjusted_m3": 2250.2817759150003, + "cut_rock_volume_m3": 3763.35761765, + "cut_rock_adjusted_m3": 4384.31162456225, + "ditch_soil_volume_m3": 1.9709999999999999, + "ditch_soil_adjusted_m3": 1.7739, + "ditch_rock_volume_m3": 156.7762425, + "ditch_rock_adjusted_m3": 182.64432251249994, + "adjusted_total_m3": 6634.59340047725, + "fill_volume_m3": 19959.30884585, + "diverted_m3": 6217.9639954512495, + "balance_m3": -13324.71544537275, + "cut_ripping_rock_volume_m3": 3763.35761765, + "cut_ripping_rock_adjusted_m3": 4384.31162456225, + "ditch_ripping_rock_volume_m3": 156.7762425, + "cut_blasting_rock_volume_m3": 0, + "cut_blasting_rock_adjusted_m3": 0, + "ditch_blasting_rock_volume_m3": 0, + "grading_fill_area_m2": 16643.147934515546, + "grading_cut_area_m2": 5638.928516414395, + "tree_removal_fill_area_m2": 16643.147934515546, + "tree_removal_cut_area_m2": 5638.928516414395, + "grubbing_area_m2": 0.0, + "road_surface_area_m2": 4976.1622767, + "benching_area_m2": 17827.2799325, + "cut_soft_rock_volume_m3": 3387.021855885, + "cut_hard_rock_volume_m3": 376.3357617649999, + "cut_soft_rock_adjusted_m3": 3945.8804621060244, + "cut_hard_rock_adjusted_m3": 438.43116245622514, + "ditch_soft_rock_volume_m3": 141.09861825, + "ditch_hard_rock_volume_m3": 15.677624250000008, + "ditch_soft_rock_adjusted_m3": 164.37989026124993, + "ditch_hard_rock_adjusted_m3": 18.264432251250014 + }, + "station_count": 8, + "summary": [ + { + "group": "흙깍기", + "kind": "토사", + "spec": "기계(굴삭기)", + "unit": "㎥", + "value": 2500.0, + "note": "" + }, + { + "group": "흙깍기", + "kind": "풍화암", + "spec": "굴삭기 + 브레카", + "unit": "㎥", + "value": null, + "note": "" + }, + { + "group": "흙깍기", + "kind": "연암", + "spec": "굴삭기 + 브레카", + "unit": "㎥", + "value": 3387.0, + "note": "" + }, + { + "group": "흙깍기", + "kind": "보통암", + "spec": "굴삭기 + 브레카", + "unit": "㎥", + "value": null, + "note": "" + }, + { + "group": "흙깍기", + "kind": "경암", + "spec": "발파 + 굴삭기", + "unit": "㎥", + "value": 376.0, + "note": "" + }, + { + "group": "측구 터파기", + "kind": "토사", + "spec": "기계", + "unit": "㎥", + "value": 2.0, + "note": "" + }, + { + "group": "측구 터파기", + "kind": "풍화암", + "spec": "굴삭기 + 브레카", + "unit": "㎥", + "value": null, + "note": "" + }, + { + "group": "측구 터파기", + "kind": "연암", + "spec": "굴삭기 + 브레카", + "unit": "㎥", + "value": 141.0, + "note": "" + }, + { + "group": "측구 터파기", + "kind": "보통암", + "spec": "굴삭기 + 브레카", + "unit": "㎥", + "value": null, + "note": "" + }, + { + "group": "측구 터파기", + "kind": "경암", + "spec": "발파 + 굴삭기", + "unit": "㎥", + "value": 16.0, + "note": "" + }, + { + "group": "보정량계", + "kind": "", + "spec": "", + "unit": "㎥", + "value": 6635.0, + "note": "" + }, + { + "group": "성토", + "kind": "", + "spec": "", + "unit": "㎥", + "value": 19959.0, + "note": "" + }, + { + "group": "유용토 소계", + "kind": "", + "spec": "", + "unit": "㎥", + "value": 6296.98399545125, + "note": "" + }, + { + "group": "횡방향유용토", + "kind": "", + "spec": "백호우", + "unit": "㎥", + "value": 6217.9639954512495, + "note": "유용토" + }, + { + "group": "종방향유용토", + "kind": "", + "spec": "백호우", + "unit": "㎥", + "value": 79.02, + "note": "유용토" + }, + { + "group": "토사운반", + "kind": "", + "spec": "BH+DT+BH", + "unit": "㎥", + "value": 174.18, + "note": "" + }, + { + "group": "토사운반", + "kind": "", + "spec": "19 TON B/D", + "unit": "㎥", + "value": 152.29999999999998, + "note": "" + }, + { + "group": "잔토처리", + "kind": "", + "spec": "백호우", + "unit": "㎥", + "value": -13325.0, + "note": "" + }, + { + "group": "채집석 공제", + "kind": "", + "spec": "돌(채집) · 고임돌 · 막자갈", + "unit": "㎥", + "value": null, + "note": "잔토처리에서 뺌" + }, + { + "group": "층따기", + "kind": "", + "spec": "백호우", + "unit": "㎥", + "value": 17827.2799325, + "note": "" + }, + { + "group": "성토면다짐", + "kind": "", + "spec": "", + "unit": "㎡", + "value": null, + "note": "비율 미정" + }, + { + "group": "표토제거", + "kind": "", + "spec": "", + "unit": "㎡", + "value": null, + "note": "" + }, + { + "group": "초류종자살포", + "kind": "", + "spec": "씨드스프레이", + "unit": "㎡", + "value": null, + "note": "비율 미정" + }, + { + "group": "법면보호공", + "kind": "", + "spec": "", + "unit": "㎡", + "value": null, + "note": "" + }, + { + "group": "지장목제거", + "kind": "", + "spec": "", + "unit": "㎡", + "value": null, + "note": "비율 미정" + }, + { + "group": "운반성토", + "kind": "", + "spec": "BH+DT+BH", + "unit": "㎥", + "value": null, + "note": "" + } + ], + "rock_ratios_pct": { + "연암": 90.0, + "경암": 10.0 + }, + "rock_c": { + "ripping_rock": 1.15, + "blasting_rock": 1.3 + }, + "haul": { + "dump_truck": { + "rows": [ + { + "q": 30.16, + "l": 72.15, + "ql": 2176.0440000000003 + }, + { + "q": 29.89, + "l": 77.68, + "ql": 2321.8552000000004 + }, + { + "q": 2.17, + "l": 60.95, + "ql": 132.2615 + }, + { + "q": 111.96, + "l": 178.37, + "ql": 19970.3052 + } + ], + "q": 174.18, + "ql": 24600.4659, + "average_m": 141.24 + }, + "dozer": { + "rows": [ + { + "q": 17.47, + "l": 41.23, + "ql": 720.2880999999999 + }, + { + "q": 0.26, + "l": 20.44, + "ql": 5.314400000000001 + }, + { + "q": 30.55, + "l": 42.94, + "ql": 1311.817 + }, + { + "q": 24.85, + "l": 30.37, + "ql": 754.6945000000001 + }, + { + "q": 44.3, + "l": 35.01, + "ql": 1550.9429999999998 + }, + { + "q": 33.9, + "l": 41.37, + "ql": 1402.4429999999998 + }, + { + "q": 0.97, + "l": 20.9, + "ql": 20.273 + } + ], + "q": 152.29999999999998, + "ql": 5765.772999999999, + "average_m": 37.86 + }, + "free_haul": { + "rows": [ + { + "q": 1.41, + "l": 10.0, + "ql": 14.1 + }, + { + "q": 5.85, + "l": 10.0, + "ql": 58.5 + }, + { + "q": 7.15, + "l": 12.79, + "ql": 91.4485 + }, + { + "q": 14.44, + "l": 10.23, + "ql": 147.7212 + }, + { + "q": 33.11, + "l": 10.0, + "ql": 331.1 + }, + { + "q": 5.36, + "l": 10.0, + "ql": 53.6 + }, + { + "q": 0.97, + "l": 3.56, + "ql": 3.4532 + }, + { + "q": 10.73, + "l": 10.0, + "ql": 107.30000000000001 + } + ], + "q": 79.02, + "ql": 807.2229000000001, + "average_m": 10.22 + } + }, + "haul_saved": true, + "route_id": 237, + "confirmed": true + }, + "/quantity/hydro": { + "stations": [ + { + "chainage_m": 85.052, + "facility": "pipe", + "kind": "", + "sheet": "pipe", + "linked": { + "area_km2": 0.010717, + "length_m": 163.9, + "height_m": 120.0, + "diameter_mm": 1000.0 + }, + "b05": { + "design_flow_m3s": 1.077, + "tc_minutes": 5.0 + } + }, + { + "chainage_m": 173.087, + "facility": "ford_bridge", + "kind": "세월교", + "sheet": "ford", + "linked": { + "area_km2": 0.141145, + "length_m": 603.3, + "height_m": 320.0, + "overflow_width_m": 10.0 + }, + "b05": { + "design_flow_m3s": 14.1846, + "tc_minutes": 5.0 + } + }, + { + "chainage_m": 258.123, + "facility": "pipe", + "kind": "", + "sheet": "pipe", + "linked": { + "area_km2": 0.004298, + "length_m": 102.0, + "height_m": 80.0, + "diameter_mm": 800.0 + }, + "b05": { + "design_flow_m3s": 0.4319, + "tc_minutes": 5.0 + } + }, + { + "chainage_m": 352.14, + "facility": "ford_bridge", + "kind": "세월교", + "sheet": "ford", + "linked": { + "area_km2": 0.232351, + "length_m": 697.9, + "height_m": 335.0, + "overflow_width_m": 10.0 + }, + "b05": { + "design_flow_m3s": 23.3505, + "tc_minutes": 5.0 + } + }, + { + "chainage_m": 440.241, + "facility": "pipe", + "kind": "", + "sheet": "pipe", + "linked": { + "area_km2": 0.005078, + "length_m": 180.0, + "height_m": 100.0, + "diameter_mm": 800.0 + }, + "b05": { + "design_flow_m3s": 0.5103, + "tc_minutes": 5.0 + } + }, + { + "chainage_m": 533.975, + "facility": "pipe", + "kind": "", + "sheet": "pipe", + "linked": { + "area_km2": 0.000164, + "length_m": 15.0, + "height_m": 10.0, + "diameter_mm": 800.0 + }, + "b05": { + "design_flow_m3s": 0.0165, + "tc_minutes": 5.0 + } + }, + { + "chainage_m": 619.708, + "facility": "pipe", + "kind": "", + "sheet": "pipe", + "linked": { + "area_km2": 0.02902, + "length_m": 268.4, + "height_m": 150.0, + "diameter_mm": 1500.0 + }, + "b05": { + "design_flow_m3s": 2.9164, + "tc_minutes": 5.0 + } + }, + { + "chainage_m": 720.442, + "facility": "pipe", + "kind": "", + "sheet": "pipe", + "linked": { + "area_km2": 0.007486, + "length_m": 110.1, + "height_m": 80.0, + "diameter_mm": 800.0 + }, + "b05": { + "design_flow_m3s": 0.7523, + "tc_minutes": 5.0 + } + }, + { + "chainage_m": 804.176, + "facility": "pipe", + "kind": "", + "sheet": "pipe", + "linked": { + "area_km2": 0.0012, + "length_m": 42.4, + "height_m": 25.0, + "diameter_mm": 800.0 + }, + "b05": { + "design_flow_m3s": 0.1206, + "tc_minutes": 5.0 + } + }, + { + "chainage_m": 899.91, + "facility": "pipe", + "kind": "", + "sheet": "pipe", + "linked": { + "area_km2": 0.001487, + "length_m": 45.8, + "height_m": 35.0, + "diameter_mm": 800.0 + }, + "b05": { + "design_flow_m3s": 0.1494, + "tc_minutes": 5.0 + } + }, + { + "chainage_m": 982.644, + "facility": "pipe", + "kind": "", + "sheet": "pipe", + "linked": { + "area_km2": 0.027089, + "length_m": 231.9, + "height_m": 150.0, + "diameter_mm": 1500.0 + }, + "b05": { + "design_flow_m3s": 2.7224, + "tc_minutes": 5.0 + } + } + ], + "defaults": { + "factor": 2.0, + "runoff": 0.8, + "area_km2": null, + "length_m": null, + "height_m": null, + "tc_min_minutes": 5.0, + "r24_mm": null, + "region": "", + "return_period": 100, + "pipe_kind": "파형강관", + "n": null, + "diameter_mm": null, + "slope_denominator": null, + "section": "full" + }, + "factors": [ + 2.0, + 1.5, + 1.2 + ], + "sections": { + "full": "만관", + "arc240": "240° 유효단면", + "eff70": "유효 70 %" + }, + "pipe_kinds": { + "파형강관": 0.024, + "흄관": 0.013, + "콘크리트관": 0.015 + }, + "sheet_defaults": { + "box": { + "n": 0.013, + "depth_ratio": null, + "box_width_m": null, + "box_height_m": null + }, + "ford": { + "n": 0.017, + "overflow_width_m": 10.0, + "slope_denominator": 10.0 + } + }, + "site": { + "region": { + "name": "영양군", + "full": "경상북도 영양군 일월면 용화리", + "source": "전처리 행정구역" + }, + "station": "영양군(도곡리)", + "periods": [ + 2, + 3, + 5, + 10, + 20, + 30, + 50, + 70, + 80, + 100, + 200, + 300, + 500 + ], + "r24": { + "2": 92.73, + "3": 109.55, + "5": 128.54, + "10": 152.78, + "20": 176.45, + "30": 190.25, + "50": 207.69, + "70": 219.24, + "80": 223.85, + "100": 231.56, + "200": 255.74, + "300": 270.05, + "500": 288.27 + } + }, + "saved": {} + }, + "/quantity/milestone": { + "data": { + "site": { + "mid_station_m": null, + "mid_road": "비포장", + "start_pin": null + }, + "bases": [], + "routes": [ + { + "id": "h-equip_heavy", + "name": "", + "address": "", + "to": "site", + "linked": false, + "dir": null, + "haul": "equip_heavy", + "legs": [ + { + "road": null, + "km": null + } + ], + "rows": [ + { + "item": "중기", + "dest": "start", + "note": "" + } + ] + }, + { + "id": "h-equip_machine", + "name": "", + "address": "", + "to": "site", + "linked": false, + "dir": null, + "haul": "equip_machine", + "legs": [ + { + "road": null, + "km": null + } + ], + "rows": [ + { + "item": "기계", + "dest": "start", + "note": "" + } + ] + }, + { + "id": "h-sand", + "name": "", + "address": "", + "to": "site", + "linked": false, + "dir": null, + "haul": "sand", + "legs": [ + { + "road": null, + "km": null + } + ], + "rows": [ + { + "item": "모래", + "dest": "start", + "note": "" + } + ] + }, + { + "id": "h-gravel", + "name": "", + "address": "", + "to": "site", + "linked": false, + "dir": null, + "haul": "gravel", + "legs": [ + { + "road": null, + "km": null + } + ], + "rows": [ + { + "item": "자갈", + "dest": "start", + "note": "" + } + ] + }, + { + "id": "h-cement", + "name": "", + "address": "", + "to": "site", + "linked": false, + "dir": null, + "haul": "cement", + "legs": [ + { + "road": null, + "km": null + } + ], + "rows": [ + { + "item": "시멘트", + "dest": "start", + "note": "" + } + ] + }, + { + "id": "h-rebar", + "name": "", + "address": "", + "to": "site", + "linked": false, + "dir": null, + "haul": "rebar", + "legs": [ + { + "road": null, + "km": null + } + ], + "rows": [ + { + "item": "철근", + "dest": "start", + "note": "" + } + ] + } + ] + }, + "site": { + "length_m": 1078.011, + "mid_default_m": 539.005, + "start_lonlat": [ + 129.0947913, + 36.8126717 + ], + "mid_lonlat": [ + 129.0983925, + 36.8120423 + ] + }, + "table": [ + { + "route": "h-equip_heavy", + "index": 0, + "item": "중기", + "company": "", + "dest": "start", + "s2": 0.0, + "s1": 0.0, + "pv": 0.0, + "un": 0.0, + "paved": 0.0, + "unpaved": 0.0, + "total": 0.0, + "note": "", + "pick": false + }, + { + "route": "h-equip_machine", + "index": 0, + "item": "기계", + "company": "", + "dest": "start", + "s2": 0.0, + "s1": 0.0, + "pv": 0.0, + "un": 0.0, + "paved": 0.0, + "unpaved": 0.0, + "total": 0.0, + "note": "", + "pick": false + }, + { + "route": "h-sand", + "index": 0, + "item": "모래", + "company": "", + "dest": "start", + "s2": 0.0, + "s1": 0.0, + "pv": 0.0, + "un": 0.0, + "paved": 0.0, + "unpaved": 0.0, + "total": 0.0, + "note": "", + "pick": false + }, + { + "route": "h-gravel", + "index": 0, + "item": "자갈", + "company": "", + "dest": "start", + "s2": 0.0, + "s1": 0.0, + "pv": 0.0, + "un": 0.0, + "paved": 0.0, + "unpaved": 0.0, + "total": 0.0, + "note": "", + "pick": false + }, + { + "route": "h-cement", + "index": 0, + "item": "시멘트", + "company": "", + "dest": "start", + "s2": 0.0, + "s1": 0.0, + "pv": 0.0, + "un": 0.0, + "paved": 0.0, + "unpaved": 0.0, + "total": 0.0, + "note": "", + "pick": false + }, + { + "route": "h-rebar", + "index": 0, + "item": "철근", + "company": "", + "dest": "start", + "s2": 0.0, + "s1": 0.0, + "pv": 0.0, + "un": 0.0, + "paved": 0.0, + "unpaved": 0.0, + "total": 0.0, + "note": "", + "pick": false + } + ], + "images": {}, + "auto_images": {}, + "place_images": {}, + "place_times": {} + }, + "/quantity/hydro/calc": { + "area_ha": 1.0717, + "r24_mm": 231.56, + "slope_ground": 0.7321537522879804, + "tc_raw_hr": 0.01857237482923163, + "tc_hr": 0.08333333333333333, + "tc_floored": true, + "intensity": 226.11736447177503, + "flow": 0.5385110655653363, + "design_flow": 1.0770221311306727, + "n": 0.024, + "depth": null, + "section_area": 0.7853981633974483, + "perimeter": 3.141592653589793, + "radius": 0.25, + "slope_bed": null, + "velocity": null, + "capacity": null, + "ok": null, + "ratio_pct": null + } +} \ No newline at end of file diff --git a/resources/tester/helper_287_ford_pavement.cjs b/resources/tester/helper_287_ford_pavement.cjs index 9b0b2fb48..be81c96c4 100644 --- a/resources/tester/helper_287_ford_pavement.cjs +++ b/resources/tester/helper_287_ford_pavement.cjs @@ -26,16 +26,6 @@ const load = (file, stubs = {}) => { const D = (f) => path.join(ROOT, "A00_Common", "design", f); const calc = load(D("design_structure_ford_calc.ts")); -/** 폼 흉내 — 상태 · setValue 만. */ -const formOf = (slope, chute) => { - const state = { slope: [{ ...slope }], chute: [{ ...chute }] }; - return { state: () => state, setValue: (c, i, v) => (state[c][0][i] = v) }; -}; -const change = (form, c, i) => { - calc.syncChuteArea(form, { id: c }, { id: i }); - return form.state().chute[0].area; -}; - // 287-2 · 287-4 그림 — DOM 흉내(속성 · 자식만) class Node { constructor(tag) { @@ -209,6 +199,32 @@ const overflowRun = (flowValue) => { }; }; +// 301-1 폼 훅 그대로(B05 물넘이 paveForm · 세월교 fordForm onChange = overflowSync onChange) +const areaRun = (widthBox, chuteBox) => { + const state = { [widthBox]: [{ width: "6" }], [chuteBox]: [{ length_m: "", area: "17" }] }; + const form = { + state: () => state, + setValue: (c, i, v) => (state[c][0][i] = v), + refresh: () => undefined, + }; + const sync = calc.overflowSync( + () => form, + widthBox, + () => 14.5776, + () => undefined, + ); + const edit = (box, item, value) => { + state[box][0][item] = value; + sync.onChange({ id: box }, { id: item }); + return state[chuteBox][0].area; + }; + return [ + edit(chuteBox, "length_m", "4"), + edit(widthBox, "width", "8"), + edit(chuteBox, "length_m", ""), + ]; +}; + const out = { overflowFlow: overflowRun(14.5776), overflowNoFlow: overflowRun(null), @@ -235,11 +251,8 @@ const out = { paveSlopeItems: load(D("design_structure_ford_pavement.ts")) .FORD_PAVEMENT_SPEC.containers.find((c) => c.id === "slope") .items.map((i) => i.id), - // 287-1 길이 넣음 = 월류 폭 × 길이 · 비움 = 기본값 · 폭 바꿈 = 길이 있을 때만 다시 셈 · 폭 비면 기본 폭 - areaByLength: change(formOf({ width: "6" }, { length_m: "4", area: "30" }), "chute", "length_m"), - areaCleared: change(formOf({ width: "6" }, { length_m: "", area: "24" }), "chute", "length_m"), - areaByWidth: change(formOf({ width: "8" }, { length_m: "2.5", area: "15" }), "slope", "width"), - areaWidthNoLength: change(formOf({ width: "8" }, { length_m: "", area: "17" }), "slope", "width"), - areaNoWidth: change(formOf({ width: "" }, { length_m: "3", area: "30" }), "chute", "length_m"), + // 301-1 면적 = 늘 손 입력 — 길이 · 월류 폭을 바꾸거나 길이를 비워도 면적 그대로(물넘이 · 세월교 폼 훅) + areaKeptPave: areaRun("slope", "chute"), + areaKeptFord: areaRun("ford", "chute_add"), }; process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_294_3_ford_extend_chute.cjs b/resources/tester/helper_294_3_ford_extend_chute.cjs index 0a3dc1bf9..c7ea1bf0b 100644 --- a/resources/tester/helper_294_3_ford_extend_chute.cjs +++ b/resources/tester/helper_294_3_ford_extend_chute.cjs @@ -64,10 +64,6 @@ const pts = (lines) => lines.map((l) => l.points.map((p) => [+p.offset.toFixed(4), +p.elevation.toFixed(4)])); const dipGround = hatch.groundLine(input.dipSamples); const fillGround = hatch.groundLine(input.fillSamples); -const calc = load(path.join(ROOT, "A00_Common", "design", "design_structure_ford_calc.ts")); -const formOf = (state) => ({ state: () => state, setValue: (c, i, v) => (state[c][0][i] = v) }); -const fordState = { ford: [{ width: "8" }], chute_add: [{ length_m: "2.5", area: "30" }] }; -calc.syncChuteArea(formOf(fordState), { id: "chute_add" }, { id: "length_m" }, "chute_add", "ford"); const guard = load(path.join(ROOT, "A00_Common", "design", "design_structure_guard.ts")); const fordPave = load(path.join(ROOT, "A00_Common", "design", "design_structure_ford_pavement.ts")); @@ -85,7 +81,6 @@ const out = { fillLine: pave.outletFillLine(input.fillDesign, fillGround), fillLineOld: pave.outletFillLine(input.fillDesign), fillChuteLength: pave.fordChuteLine(input.fillDesign, { length_m: 1 }, fillGround), - fordArea: fordState.chute_add[0].area, chuteItems: guard.CHUTE_ITEMS.map((i) => i.id), fordPaveChuteSame: fordPave.FORD_PAVEMENT_SPEC.containers.find((c) => c.id === "chute").items === diff --git a/resources/tester/helper_302_3_print_quantity.mjs b/resources/tester/helper_302_3_print_quantity.mjs new file mode 100644 index 000000000..9085c3003 --- /dev/null +++ b/resources/tester/helper_302_3_print_quantity.mjs @@ -0,0 +1,152 @@ +// 302-3 인쇄 미리보기 수량산출 — 화면 코드를 node 로 묶어 모으기 결과와 B08 화면 표 짜기를 나란히 냄. +// node helper_302_3_print_quantity.mjs <저장소 뿌리> +// 표준입력 = { project, responses: {"/quantity/…": 응답}, image_b64, drafts: [[이름, 값]], screen } +// (screen = false → 화면 표 짜기 생략 · 응답을 일부러 뺀 실패 견본) +// 표준출력 끝 줄 = { sheets: [모으기 쪽 요약], screen: {B08 화면 표 짜기 요약}, posts: [hydro/calc 본문] } +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const root = path.resolve(process.argv[2] ?? "."); +const { build } = await import( + pathToFileURL(path.join(root, "config/node_modules/vite/dist/node/index.js")).href +); +const input = JSON.parse(fs.readFileSync(0, "utf-8")); + +// 화면 · 세션만 흉내 — 이 길은 DOM 을 안 그림 +class Node { + constructor() { + this.children = []; + this.classList = { toggle() {}, add() {}, remove() {}, contains: () => false }; + this.style = {}; + this.dataset = {}; + } + append(...kids) { + this.children.push(...kids); + } + appendChild(kid) { + this.children.push(kid); + return kid; + } + addEventListener() {} + setAttribute() {} +} +const session = new Map(); +const storage = { + getItem: (k) => (session.has(k) ? session.get(k) : null), + setItem: (k, v) => session.set(k, String(v)), + removeItem: (k) => session.delete(k), +}; +globalThis.document = { + createElement: () => new Node(), + createElementNS: () => new Node(), + createTextNode: () => new Node(), + head: new Node(), + body: new Node(), + addEventListener() {}, + documentElement: new Node(), +}; +globalThis.window = { + sessionStorage: storage, + localStorage: storage, + location: { origin: "http://localhost", hash: "" }, + addEventListener() {}, + setTimeout: (...a) => setTimeout(...a), + clearTimeout: (id) => clearTimeout(id), +}; +globalThis.sessionStorage = storage; +globalThis.localStorage = storage; + +const posts = []; +const png = Buffer.from(input.image_b64, "base64"); +globalThis.fetch = async (url, init = {}) => { + const at = String(url) + .replace(/^.*\/projects\/[^/]+/, "") + .split("?")[0]; + if (at === "/quantity/hydro/calc") posts.push(JSON.parse(init.body)); + if (at === "/quantity/milestone/image") + return new Response(png, { status: 200, headers: { "Content-Type": "image/png" } }); + const body = input.responses[at]; + return body + ? new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + : new Response(JSON.stringify({ detail: `없음 ${at}` }), { status: 404 }); +}; + +/** 표 요약 — 열 머리(묶음 › 이름) · 줄 · 합계 */ +const shape = (t) => ({ + heads: t.columns.map((c) => [...(c.group ?? []), c.label].join("›")), + rows: t.rows, + total: t.total ?? null, + mergeSame: t.mergeSame ?? null, + frozen: t.frozen ?? 0, +}); + +const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "aislo-302-3-")); +try { + await build({ + configFile: false, + logLevel: "silent", + root, + resolve: { + alias: { + "@ui": path.join(root, "ui_template"), + "@config": path.join(root, "config"), + "@util": path.join(root, "common_util"), + }, + }, + build: { + outDir, + minify: false, + target: "es2022", + lib: { + entry: path.join(root, "resources/tester/helper_302_3_print_quantity_entry.ts"), + formats: ["es"], + fileName: "entry", + }, + }, + }); + const mod = await import(pathToFileURL(path.join(outDir, "entry.js")).href); + const p = input.project; + for (const [name, value] of input.drafts ?? []) mod.writeState(name, value, p); + + const sheets = (await mod.collectQuantitySheets(p)).map((s) => ({ + id: s.id, + name: s.name, + orientation: s.orientation ?? null, + images: s.images ?? [], + ...shape(s.table), + })); + + if (input.screen === false) { + console.log(JSON.stringify({ sheets, screen: null, posts })); + process.exit(0); + } + // B08 화면이 부르는 같은 함수 — 같은 응답 · 같은 캐시 + const screen = {}; + screen.summary = shape(mod.summaryTable(await mod.loadSummary(p)).options); + const m = await mod.loadMaterials(p); + const ms = m.calc(m.payload, m.overrides); + for (const id of ["material", "concrete", "quantity"]) + screen[`material:${id}`] = shape(mod.materialOptions(mod.materialTable(id, ms, m.overrides))); + const { table, interval } = await mod.loadEarthwork(p); + screen["earthwork:summary"] = shape(mod.earthworkSummaryOptions(table)); + screen["earthwork:sheet"] = shape(mod.earthworkSheetOptions(table, interval)); + for (const id of ["haul", "free"]) { + const { groups, caption } = mod.HAUL_SHEETS[id](table); + const { options, averages } = mod.haulOptions(groups, caption); + screen[`earthwork:${id}`] = { ...shape(options), averages }; + } + screen.earthworkNames = mod.EARTHWORK_SHEETS.map((s) => s.label); + const h = await mod.loadHydro(p); + screen.hydroInputs = h.sheet.stations.map((st) => mod.hydroInputs(h.sheet, st, h.drafts)); + screen.milestone = shape( + mod.milestoneTable(mod.liveMilestone(p), mod.siteInfo()?.mid_default_m ?? null).options, + ); + console.log(JSON.stringify({ sheets, screen, posts })); +} finally { + fs.rmSync(outDir, { recursive: true, force: true }); +} diff --git a/resources/tester/helper_302_3_print_quantity_entry.ts b/resources/tester/helper_302_3_print_quantity_entry.ts new file mode 100644 index 000000000..029e76fa7 --- /dev/null +++ b/resources/tester/helper_302_3_print_quantity_entry.ts @@ -0,0 +1,25 @@ +/* ============================================================================= + * helper_302_3_print_quantity_entry.ts + * 인쇄 미리보기 수량산출 시험(`test_302_3_print_quantity.py`)이 묶는 진입점 — + * 모으기(`collectQuantitySheets`) + B08 화면이 쓰는 같은 자료 읽기 · 표 짜기 함수 + 캐시 쓰기. + * ========================================================================== */ + +export { collectQuantitySheets } from "../../B09_Estimation/draft/B09_Draft_Print_Quantity"; +export { writeState } from "../../A00_Common/b_page_state"; +export { loadSummary, summaryTable } from "../../B08_Quantity/B08_Quantity_Summary_Table"; +export { + loadMaterials, + materialOptions, + materialTable, +} from "../../B08_Quantity/B08_Quantity_Material_Tables"; +export { + EARTHWORK_SHEETS, + earthworkSheetOptions, + earthworkSummaryOptions, + HAUL_SHEETS, + haulOptions, + loadEarthwork, +} from "../../B08_Quantity/B08_Quantity_Earthwork_Tables"; +export { hydroInputs, loadHydro } from "../../B08_Quantity/B08_Quantity_Hydro_Table"; +export { milestoneTable } from "../../B08_Quantity/B08_Quantity_Milestone_Table"; +export { liveMilestone, siteInfo } from "../../B08_Quantity/B08_Quantity_UI_Milestone_State"; diff --git a/resources/tester/print_cost/print_cost_entry.ts b/resources/tester/print_cost/print_cost_entry.ts new file mode 100644 index 000000000..67bb250a7 --- /dev/null +++ b/resources/tester/print_cost/print_cost_entry.ts @@ -0,0 +1,44 @@ +/* ============================================================================= + * print_cost_entry.ts (PLAN 302-4 시험 진입) + * 원가계산 인쇄 쪽(`B09_Draft_Print_Cost.costSheetsOf`)을 화면 표 짜기(`tabTableOf`)와 대조. + * vite `--ssr` 로 번들해 node 로 실행 — 인자 = payload JSON 경로 · 출력 = 쪽마다 대조 JSON. + * ========================================================================== */ + +import { readFileSync } from "node:fs"; +import { costSheetsOf } from "../../../B09_Estimation/draft/B09_Draft_Print_Cost"; +import { tabTableOf } from "../../../B09_Estimation/B09_Estimation_UI_Builders"; +import { computeSheets } from "../../../B09_Estimation/B09_Estimation_UI_Shell"; +import type { TabId } from "../../../B09_Estimation/B09_Estimation_UI_Tabs"; + +const payload = JSON.parse(readFileSync(process.argv[2], "utf-8")); +const user = payload.user ?? {}; +const ctx = { sheets: computeSheets(payload, user), payload, user, setUser: () => {} }; + +const out = costSheetsOf(payload, user).map((sheet) => { + const spec = tabTableOf(sheet.id as TabId, ctx); + const screen = !spec || typeof spec === "string" ? null : spec; + const t = sheet.table; + const n = t.rows.length; + return { + id: sheet.id, + name: sheet.name, + orientation: sheet.orientation, + rows: n, + screenRows: screen ? screen.rows.length : 0, + sameRows: JSON.stringify(t.rows) === JSON.stringify(screen?.rows ?? []), + total: t.total ?? null, + screenTotal: screen?.total ?? null, + columns: t.columns.map((c) => c.key), + screenColumns: screen ? screen.columns.map((c) => c.key) : [], + groups: t.columns.filter((c) => c.group?.length).length, + level: t.level ? { key: t.level.key, tree: t.level.tree ?? false } : null, + levels: t.level ? Array.from({ length: n }, (_, i) => t.level?.of(i) ?? null) : [], + rowClass: t.rowClass ? Array.from({ length: n }, (_, i) => t.rowClass?.(i) ?? null) : [], + mergeSame: t.mergeSame ?? [], + caption: t.caption, + interactive: ["editable", "onEdit", "onCellClick", "onRowClick", "cellClass", "focus"].filter( + (k) => k in t, + ), + }; +}); +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/print_frame/print_frame_entry.ts b/resources/tester/print_frame/print_frame_entry.ts new file mode 100644 index 000000000..f56bfb438 --- /dev/null +++ b/resources/tester/print_frame/print_frame_entry.ts @@ -0,0 +1,15 @@ +/* ============================================================================= + * print_frame_entry.ts (PLAN 302-1 시험 진입) + * 원가계산 영역(`collectCostSheets` 몸통 `costSheetsOf`) → 인쇄 페이지와 같은 서버 몸(`pdfBody` · `xlsxBody`). + * vite `--ssr` 로 번들해 node 로 실행 — 인자 = payload JSON 경로 · 출력 = {pdf, xlsx} JSON. + * ========================================================================== */ + +import { readFileSync } from "node:fs"; +import { costSheetsOf } from "../../../B09_Estimation/draft/B09_Draft_Print_Cost"; +import { pdfBody, xlsxBody } from "../../../B09_Estimation/draft/B09_Draft_Print_Table"; + +const payload = JSON.parse(readFileSync(process.argv[2], "utf-8")); +const sheets = costSheetsOf(payload, payload.user ?? {}); +console.log( + JSON.stringify({ pdf: pdfBody("원가계산", sheets), xlsx: xlsxBody("원가계산", sheets) }), +); diff --git a/resources/tester/print_frame/print_quantity_entry.ts b/resources/tester/print_frame/print_quantity_entry.ts new file mode 100644 index 000000000..de4ed8c16 --- /dev/null +++ b/resources/tester/print_frame/print_quantity_entry.ts @@ -0,0 +1,8 @@ +/* ============================================================================= + * print_quantity_entry.ts (PLAN 302-1 시험 진입) + * 수량산출 영역(`collectQuantitySheets`) → 인쇄 페이지와 같은 서버 몸(`pdfBody` · `xlsxBody`). + * `print_quantity_seam.mjs` 가 vite 로 묶어 씀. + * ========================================================================== */ + +export { collectQuantitySheets } from "../../../B09_Estimation/draft/B09_Draft_Print_Quantity"; +export { pdfBody, xlsxBody } from "../../../B09_Estimation/draft/B09_Draft_Print_Table"; diff --git a/resources/tester/print_frame/print_quantity_seam.mjs b/resources/tester/print_frame/print_quantity_seam.mjs new file mode 100644 index 000000000..f9f821be4 --- /dev/null +++ b/resources/tester/print_frame/print_quantity_seam.mjs @@ -0,0 +1,110 @@ +// 302-1 수량산출 이음매 — 영역 모으기 → 인쇄 페이지 서버 몸(pdfBody · xlsxBody) 을 node 로. +// node print_quantity_seam.mjs <저장소 뿌리> +// 표준입력 = { project, responses: {"/quantity/…": 응답}, image_b64 } (302-3 시험 자료 꼴) +// 표준출력 끝 줄 = { pdf: {title, sheets}, xlsx: {title, sheets} } +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const root = path.resolve(process.argv[2] ?? "."); +const { build } = await import( + pathToFileURL(path.join(root, "config/node_modules/vite/dist/node/index.js")).href +); +const input = JSON.parse(fs.readFileSync(0, "utf-8")); + +// 화면 · 세션만 흉내 — 이 길은 DOM 을 안 그림 +class Node { + constructor() { + this.children = []; + this.classList = { toggle() {}, add() {}, remove() {}, contains: () => false }; + this.style = {}; + this.dataset = {}; + } + append(...kids) { + this.children.push(...kids); + } + appendChild(kid) { + this.children.push(kid); + return kid; + } + addEventListener() {} + setAttribute() {} +} +const session = new Map(); +const storage = { + getItem: (k) => (session.has(k) ? session.get(k) : null), + setItem: (k, v) => session.set(k, String(v)), + removeItem: (k) => session.delete(k), +}; +globalThis.document = { + createElement: () => new Node(), + createElementNS: () => new Node(), + createTextNode: () => new Node(), + head: new Node(), + body: new Node(), + addEventListener() {}, + documentElement: new Node(), +}; +globalThis.window = { + sessionStorage: storage, + localStorage: storage, + location: { origin: "http://localhost", hash: "" }, + addEventListener() {}, + setTimeout: (...a) => setTimeout(...a), + clearTimeout: (id) => clearTimeout(id), +}; +globalThis.sessionStorage = storage; +globalThis.localStorage = storage; + +const png = Buffer.from(input.image_b64, "base64"); +globalThis.fetch = async (url) => { + const at = String(url) + .replace(/^.*\/projects\/[^/]+/, "") + .split("?")[0]; + if (at === "/quantity/milestone/image") + return new Response(png, { status: 200, headers: { "Content-Type": "image/png" } }); + const body = input.responses[at]; + return body + ? new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + : new Response(JSON.stringify({ detail: `없음 ${at}` }), { status: 404 }); +}; + +const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "aislo-302-1-")); +try { + await build({ + configFile: false, + logLevel: "silent", + root, + resolve: { + alias: { + "@ui": path.join(root, "ui_template"), + "@config": path.join(root, "config"), + "@util": path.join(root, "common_util"), + }, + }, + build: { + outDir, + minify: false, + target: "es2022", + lib: { + entry: path.join(root, "resources/tester/print_frame/print_quantity_entry.ts"), + formats: ["es"], + fileName: "entry", + }, + }, + }); + const mod = await import(pathToFileURL(path.join(outDir, "entry.js")).href); + const sheets = await mod.collectQuantitySheets(input.project); + console.log( + JSON.stringify({ + pdf: mod.pdfBody("수량산출", sheets), + xlsx: mod.xlsxBody("수량산출", sheets), + }), + ); +} finally { + fs.rmSync(outDir, { recursive: true, force: true }); +} diff --git a/resources/tester/test_110_b08_bed_slope.py b/resources/tester/test_110_b08_bed_slope.py index 27e57b27d..875c4e906 100644 --- a/resources/tester/test_110_b08_bed_slope.py +++ b/resources/tester/test_110_b08_bed_slope.py @@ -88,7 +88,9 @@ def test_연결값으로_판정이_나옴() -> None: def test_저장된_측점_null_이_연결값을_덮지_않음() -> None: """화면은 저장본 · 초안의 null 을 연결값 칸에서 건너뜀 — 연결값으로 실어야 저장된 측점도 바뀜.""" - ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Hydro.ts").read_text(encoding="utf-8") + ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Hydro.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity" / "B08_Quantity_Hydro_Table.ts" + ).read_text(encoding="utf-8") assert "v != null || !(k in linkedOf(station))" in ui assert "...station.linked," in ui diff --git a/resources/tester/test_120_b08_drain_summary.py b/resources/tester/test_120_b08_drain_summary.py index 2979af220..ca621d888 100644 --- a/resources/tester/test_120_b08_drain_summary.py +++ b/resources/tester/test_120_b08_drain_summary.py @@ -270,7 +270,9 @@ def test_열_폭_맨_아래_단_글만_44_100(): def test_화면_조합_열_빼기_폭_머리_줄바꿈(): - ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity/B08_Quantity_Summary_Table.ts" + ).read_text(encoding="utf-8") assert "served.has(point.facility" in ui and "served.has(item.type_id)" in ui assert "widthOf(column.label, ...texts)" in ui and "widthOf(column.name)" in ui assert "widthOf(stationHead, ...labels)" in ui diff --git a/resources/tester/test_121_b08_material_sheets.py b/resources/tester/test_121_b08_material_sheets.py index 8cbcd0a18..e789ec9d1 100644 --- a/resources/tester/test_121_b08_material_sheets.py +++ b/resources/tester/test_121_b08_material_sheets.py @@ -225,7 +225,9 @@ def test_저장은_고친_칸만_서버가_다시_셈(tmp_path, monkeypatch): def test_화면_연결(): page = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Page.ts").read_text(encoding="utf-8") assert "renderMaterialSheet(body, projectId, picked)" in page - ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Material.ts").read_text(encoding="utf-8") + ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Material.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity" / "B08_Quantity_Material_Tables.ts" + ).read_text(encoding="utf-8") assert "computeMaterialSheets(" in ui # 조작 중 = 짝 계산 assert 'method: "PUT"' in ui and "setTimeout" not in ui # [저장]만 · 자동 저장 없음 assert "일위대가 재료와 별개" in ui diff --git a/resources/tester/test_122_b08_options.py b/resources/tester/test_122_b08_options.py index 6db3d80ed..e063a0454 100644 --- a/resources/tester/test_122_b08_options.py +++ b/resources/tester/test_122_b08_options.py @@ -169,10 +169,14 @@ def test_좌측_패널_컨테이너_셋과_저장_줄(): def test_표가_옵션_바뀜에_바로_다시_셈(): - ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity/B08_Quantity_Summary_Table.ts" + ).read_text(encoding="utf-8") assert "recountCouplings(saved, liveOnePipe(projectId, saved.one_length_m))" in ui assert "onOptionsChange(host," in ui - earth = (ROOT / "B08_Quantity/B08_Quantity_UI_Earthwork.ts").read_text(encoding="utf-8") + earth = (ROOT / "B08_Quantity/B08_Quantity_UI_Earthwork.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity/B08_Quantity_Earthwork_Tables.ts" + ).read_text(encoding="utf-8") assert "splitRockSummary(summary, table.totals, soft)" in earth assert "onOptionsChange(host," in earth assert ( diff --git a/resources/tester/test_124_b08_surcharge_panel.py b/resources/tester/test_124_b08_surcharge_panel.py index 52faf1c38..2789ad7b5 100644 --- a/resources/tester/test_124_b08_surcharge_panel.py +++ b/resources/tester/test_124_b08_surcharge_panel.py @@ -123,7 +123,9 @@ def test_범위_밖_모르는_칸은_거절(): def test_화면_연결(): - ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Material.ts").read_text(encoding="utf-8") + ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Material.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity/B08_Quantity_Material_Tables.ts" + ).read_text(encoding="utf-8") assert "liveSurcharge(projectId, payload.surcharge_pct)" in ui assert "onOptionsChange(host," in ui assert 'key === "surcharge" && !rows[keep[i]].panel' in ui and "할증 = 좌측 패널" in ui diff --git a/resources/tester/test_143_1_b08_linked_snapshots.py b/resources/tester/test_143_1_b08_linked_snapshots.py index 6eb698a8f..360925cb0 100644 --- a/resources/tester/test_143_1_b08_linked_snapshots.py +++ b/resources/tester/test_143_1_b08_linked_snapshots.py @@ -184,7 +184,9 @@ def test_미연결_시설_칸_전부_연결_여부(root): def test_화면_조합_이름_구조물도는_사본에서(): - ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity/B08_Quantity_Summary_Table.ts" + ).read_text(encoding="utf-8") assert "loadPickerRows" not in ui # 층 목록 GET 안 씀 assert "linkedOnly(" in ui assert "unlinkedAt(" in ui and "B08_Quantity_Summary_LampTip" in ui # 154-1 · 198-1 미연결 램프 diff --git a/resources/tester/test_143_3_b08_material_cards.py b/resources/tester/test_143_3_b08_material_cards.py index 5253fcfa4..926c7e346 100644 --- a/resources/tester/test_143_3_b08_material_cards.py +++ b/resources/tester/test_143_3_b08_material_cards.py @@ -7,8 +7,12 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] -MATERIAL = (ROOT / "B08_Quantity/B08_Quantity_UI_Material.ts").read_text(encoding="utf-8") -SUMMARY = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") +MATERIAL = (ROOT / "B08_Quantity/B08_Quantity_UI_Material.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity/B08_Quantity_Material_Tables.ts" +).read_text(encoding="utf-8") +SUMMARY = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity/B08_Quantity_Summary_Table.ts" +).read_text(encoding="utf-8") def test_자재_셋_값_없는_줄_숨김(): diff --git a/resources/tester/test_145_1_b09_sangeun.py b/resources/tester/test_145_1_b09_sangeun.py index 3c1af96ab..087203723 100644 --- a/resources/tester/test_145_1_b09_sangeun.py +++ b/resources/tester/test_145_1_b09_sangeun.py @@ -178,7 +178,8 @@ def test_작업량_Q는_소수_둘(): def test_화면_탭_둘_서버_산근_그대로(): shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") - assert "sg_list: buildSgList," in shell and "sg: buildSg," in shell + builders = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Builders.ts").read_text("utf-8") + assert "sg_list: buildSgList," in builders and "sg: buildSg," in builders # 303-2 표 짜기 assert 'const SERVER_ONLY = ["sg", "sg_list", "sg_gaps", "sg_notes"] as const;' in shell assert "gaps: [...base.gaps, ...(server.sg_gaps || [])" in shell # 뒤에 대가(145-2) gaps 이음 tabs = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Sangeun.ts").read_text("utf-8") diff --git a/resources/tester/test_154_2_b08_direct_link.py b/resources/tester/test_154_2_b08_direct_link.py index 12bd79719..8b9e34f65 100644 --- a/resources/tester/test_154_2_b08_direct_link.py +++ b/resources/tester/test_154_2_b08_direct_link.py @@ -157,5 +157,7 @@ def test_화면_초안이면_막고_고르기_모달_다시_그림(): assert ".link(slot.copy, facility.facility, item)" in ui api = (ROOT / "ui_template/ui_template_item_picker_project.ts").read_text(encoding="utf-8") assert "/link`" in api - page = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + page = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity/B08_Quantity_Summary_Table.ts" + ).read_text(encoding="utf-8") assert "host.replaceWith(fresh)" in page and "unlinkedAt(unlinked, station)" in page diff --git a/resources/tester/test_157_2_b09_sangeun_views.py b/resources/tester/test_157_2_b09_sangeun_views.py index b8cbb03d4..068632436 100644 --- a/resources/tester/test_157_2_b09_sangeun_views.py +++ b/resources/tester/test_157_2_b09_sangeun_views.py @@ -62,20 +62,19 @@ def test_157_2_두_보기_같은_값(): def test_157_2_단추_바꿈(): - out = _node( - "\nconsole.log(JSON.stringify([viewLabel('table'), viewLabel('cards')," - " flipView('table'), flipView(flipView('table'))]));" - ) - assert out == ["박스보기", "표로 보기", "cards", "table"] + out = _node("\nconsole.log(JSON.stringify([viewLabel('table'), viewLabel('cards')]));") + assert out == ["호표보기", "박스보기"] # 304-1 단추 글 = 그 보기 이름 def test_157_2_기억_실패_때_카드(): out = _node( - "\nconst a = [readGroupView('sg', null), readGroupView('sg', { sg: 'x' })," - " readGroupView('sg', { dg: 'table' }), readGroupView('sg', { sg: 'table' })];" + "\nconst a = [readGroupView(null), readGroupView({ sg: 'x' }), readGroupView({ dg: 'table' })," + " readGroupView({ sg: 'table' }), readGroupView('table'), readGroupView('x')," + " readGroupView({ dg: 'cards', sg: 'table' })];" "\nconsole.log(JSON.stringify(a));" ) - assert out == ["cards", "cards", "cards", "table"] + # 304-1 한 값 · 옛 탭별 값 = dg 먼저 · 없으면 다른 탭 · 모르는 값 = 카드 + assert out == ["cards", "cards", "table", "table", "table", "cards", "cards"] def test_157_2_화면_잇기(): diff --git a/resources/tester/test_160_2_b08_unassigned.py b/resources/tester/test_160_2_b08_unassigned.py index e2e351912..c9fe2b6b1 100644 --- a/resources/tester/test_160_2_b08_unassigned.py +++ b/resources/tester/test_160_2_b08_unassigned.py @@ -60,7 +60,9 @@ def test_빈_조합_칸_글(): def test_화면_연결(): - summary = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + summary = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity/B08_Quantity_Summary_Table.ts" + ).read_text(encoding="utf-8") assert ( 'unassignedText(unlinkedAt(unlinked, station), L("B08_Quantity_Link_Body"))' in summary ) # 칸 이름 = 모달과 같은 규칙 diff --git a/resources/tester/test_163_b08_modal.py b/resources/tester/test_163_b08_modal.py index 732c7c7a5..bc44600bd 100644 --- a/resources/tester/test_163_b08_modal.py +++ b/resources/tester/test_163_b08_modal.py @@ -11,7 +11,9 @@ ROOT = Path(__file__).resolve().parents[2] LINK = (ROOT / "B08_Quantity/B08_Quantity_UI_Link.ts").read_text(encoding="utf-8") DRAWING = (ROOT / "B08_Quantity/B08_Quantity_UI_Drawing.ts").read_text(encoding="utf-8") CSS = (ROOT / "B08_Quantity/B08_Quantity_UI_Drawing.css").read_text(encoding="utf-8") -MATERIAL = (ROOT / "B08_Quantity/B08_Quantity_UI_Material.ts").read_text(encoding="utf-8") +MATERIAL = (ROOT / "B08_Quantity/B08_Quantity_UI_Material.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity/B08_Quantity_Material_Tables.ts" +).read_text(encoding="utf-8") LOCALE = (ROOT / "ui_template/ui_template_locale_b4.ts").read_text(encoding="utf-8") diff --git a/resources/tester/test_167_1_b08_pipe_owner_id.py b/resources/tester/test_167_1_b08_pipe_owner_id.py index f56bf9186..aaa34ac00 100644 --- a/resources/tester/test_167_1_b08_pipe_owner_id.py +++ b/resources/tester/test_167_1_b08_pipe_owner_id.py @@ -51,6 +51,8 @@ def test_한_칸만_연결_때_조합_하나(): def test_화면_연결(): - summary = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + summary = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity/B08_Quantity_Summary_Table.ts" + ).read_text(encoding="utf-8") assert "...(saved?.facilities ?? []).map((f) => f.id)" in summary assert "pipeOwnerId(point.chainage_m, ids) ?? pipeStructureId(point.chainage_m)" in summary diff --git a/resources/tester/test_171_3_b09_group_tabs.py b/resources/tester/test_171_3_b09_group_tabs.py index 139aab8d6..36668ade1 100644 --- a/resources/tester/test_171_3_b09_group_tabs.py +++ b/resources/tester/test_171_3_b09_group_tabs.py @@ -118,27 +118,27 @@ def test_단가산출근거_카드(): assert out["links"] == [None, {"tab": "labor", "key": "LB2"}] -def test_보기_기억_탭마다(): +def test_보기_한_값_열_수_탭마다(): out = run( [COMMON, TABS, GROUPS], - "\nconst a = [readGroupView('dg'), readGroupCols('dg')];" - "\nrememberGroup('b09-group-view', 'dg', 'table');" + "\nconst a = [readGroupView(), readGroupCols('dg')];" + "\nkept.set('b09-group-view', JSON.stringify({ machine_blocks: 'table' }));" # 옛 탭별 값 "\nrememberGroup('b09-group-cols', 'sg', 3);" - "\nconst b = [readGroupView('dg'), readGroupView('sg'), readGroupView('machine_blocks')," + "\nconst b = [groupView(), readGroupView(), readGroupView()," " readGroupCols('sg'), readGroupCols('dg'), readGroupCols('dg', { dg: 9 })];" "\nconsole.log(JSON.stringify({ a, b, kept: Object.fromEntries(kept)," " tabs: GROUP_TABS, other: ['bill', 'sum_material', 'sum_labor', 'sum_expense'," " 'sum_machine', 'dg_list'].map(isGroupTab) }));", ) assert out["a"] == ["cards", 2] # 기본 = 카드 · 가로 2 - assert out["b"] == ["table", "cards", "cards", 3, 2, 2] - assert json.loads(out["kept"]["b09-group-view"]) == {"dg": "table"} + assert out["b"] == ["table", "table", "table", 3, 2, 2] # 304-1 셋 탭 한 값 + assert json.loads(out["kept"]["b09-group-view"]) == "table" # 첫 읽기에 옛 값 → 한 값 assert json.loads(out["kept"]["b09-group-cols"]) == {"sg": 3} assert out["tabs"] == ["dg", "machine_blocks", "sg"] assert out["other"] == [False] * 6 # 집계표 넷 · 설계내역서 · 목록표 = 대상 밖 -def test_화면_보기_바꿈_누름_이동(): +def test_화면_보기_고름_누름_이동(): out = run( [COMMON, TABS, f"{B09}Labor.ts", GROUPS], f"\nconst spec = buildDaegaBlocks({{ sheets: {{ daega_blocks: [{json.dumps(DG, ensure_ascii=False)}] }} }});" @@ -147,26 +147,25 @@ def test_화면_보기_바꿈_누름_이동(): " redraw: () => redraws++, setTools: (t) => calls.push(t), makeTable: () => ({ root: document.createElement('table')," " focusRow: (i) => focused.push(i) }) };" "\nconst [view] = renderGroupPanel(o);" - "\nconst btn = calls[0].find((e) => e.dataset.b09GroupView);" - "\nconst label = btn.textContent;" + "\nconst label = calls.flat().filter((e) => e.dataset.b09GroupView).length;" "\nconst lit = view.querySelector('[data-at=\"3\"]').classList.contains('is-focus');" "\nconst box = view.querySelector('.ui-group-card').classList.contains('is-pick');" "\nview.querySelector('[data-at=\"2\"]').click();" "\nview.querySelector('[data-at=\"0\"]').click();" - "\nbtn.click();" + "\nconst before = calls.length; chooseGroupView('table');" "\nconst [view2] = renderGroupPanel(o);" "\nconsole.log(JSON.stringify({ label, table: view.dataset.b09Table, gone, lit, box, redraws," - " label2: calls[1].find((e) => e.dataset.b09GroupView).textContent, tag2: view2.tagName, focused," + " tools2: calls.length - before, tag2: view2.tagName, focused," " kept: Object.fromEntries(kept) }));", RAW, ) - assert out["label"] == "표로 보기" and out["table"] == "dg" # 기본 = 카드 + assert out["label"] == 0 and out["table"] == "dg" # 기본 = 카드 · 304-1 탭 줄 보기 단추 없음 assert out["gone"] == [{"tab": "labor", "key": "L00005"}, None] # 줄 누름 = 표와 같은 이동 assert out["lit"] is False # 248-1 이동해 온 줄 = 빨강 하나(줄 강조 안 붙임) assert out["box"] is True # 그 호표 박스 = 붉은 외곽선(181-2) - assert out["redraws"] == 1 - assert out["label2"] == "박스보기" and out["tag2"] == "TABLE" and out["focused"] == [3] - assert json.loads(out["kept"]["b09-group-view"]) == {"dg": "table"} + assert out["redraws"] == 0 + assert out["tools2"] == 0 and out["tag2"] == "TABLE" and out["focused"] == [3] + assert json.loads(out["kept"]["b09-group-view"]) == "table" def test_잇기(): diff --git a/resources/tester/test_181_2_b09_box_pick.py b/resources/tester/test_181_2_b09_box_pick.py index 0233c6458..211dd7009 100644 --- a/resources/tester/test_181_2_b09_box_pick.py +++ b/resources/tester/test_181_2_b09_box_pick.py @@ -1,6 +1,6 @@ """181-2 박스보기 — 이름 · 접기 없음 · 목록표에서 고른 호표 = 박스 붉은 외곽선 · 표 보기 = 줄 구간 전체. -- 단추 글 「박스보기」 ↔ 「표로 보기」 · 요약(접기) 보기 지움 +- 단추 글 「박스보기」 · 「호표보기」(304-1 좌측 스위치) · 요약(접기) 보기 지움 - 표 보기 = 고른 호표 줄 구간(키 `|` 앞머리 같은 줄) 모두 `is-pick` · 첫 줄 `is-pick-top` · 끝 줄 `is-pick-end` - 붉은 색 = `--color-danger`(B06 횡단 카드 고름 꼴) """ @@ -52,7 +52,7 @@ def test_표_보기_줄_class_넘김(): "\nconst o = { tab: 'dg', spec, hint: null, focus: 'B|1', goto: () => {}, redraw: () => {}, setTools: () => {}," " makeTable: (e) => { extra = e; return { root: document.createElement('table')," " focusRow: () => {} }; } };" - "\nchosen.set('dg', 'table');" + "\nchooseGroupView('table');" "\nrenderGroupPanel(o);" "\nconsole.log(JSON.stringify(spec.rows.map((_, i) => extra(i))));", ) @@ -63,7 +63,7 @@ def test_표_보기_줄_class_넘김(): def test_글_접기_없음_CSS_잇기(): groups = (ROOT / GROUPS).read_text("utf-8") - assert '"표로 보기" : "박스보기"' in groups and "호표별 보기" not in groups + assert '"박스보기" : "호표보기"' in groups and "호표별 보기" not in groups # 304-1 글 common = (ROOT / COMMON).read_text("utf-8") for gone in ("isSummary", "GROUP_SUMMARY_OVER", "is-closed", "모두 펼침", "모두 접기"): assert gone not in common diff --git a/resources/tester/test_183_3_b09_applied_pick.py b/resources/tester/test_183_3_b09_applied_pick.py index 3e4f90e27..533f89669 100644 --- a/resources/tester/test_183_3_b09_applied_pick.py +++ b/resources/tester/test_183_3_b09_applied_pick.py @@ -101,12 +101,14 @@ def test_배선_수기_적용_칸_출처_돌림_없앰(): assert 'editable: (_i, key) => key === "p:견적",' in compare assert "nextPick" not in base and 'key === "applied_from"' not in compare assert 'key === `p:${rows[i]?.applied_from}` ? "is-picked"' in compare - assert 'key === "applied" && rows[i] ? chooseApplied(rows[i]) : null' in compare # 257-3 메뉴 둘 공용 + assert ( + 'key === "applied" && rows[i] ? chooseApplied(rows[i]) : null' in compare + ) # 257-3 메뉴 둘 공용 assert "delete cell.applied" in compare shell = _src("B09_Estimation/B09_Estimation_UI_Shell.ts") - assert ( - "cellClass: spec.cellClass," in shell and "openMenuAt(box.left, box.bottom, items)" in shell - ) + builders = _src("B09_Estimation/B09_Estimation_UI_Builders.ts") # 303-2 옵션 짜기 + assert "cellClass: spec.cellClass," in builders and "...tableOptionsOf(spec, tab)," in shell + assert "openMenuAt(box.left, box.bottom, items)" in shell edit = _src("B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts") assert "export function openMenuAt(" in edit and "const ctxMenu = sharedMenu();" in edit css = _src("B09_Estimation/B09_Estimation_UI_Page.css") diff --git a/resources/tester/test_184_3_b09_bill_menu.py b/resources/tester/test_184_3_b09_bill_menu.py index b3daf1fba..8fbb62580 100644 --- a/resources/tester/test_184_3_b09_bill_menu.py +++ b/resources/tester/test_184_3_b09_bill_menu.py @@ -286,4 +286,4 @@ def test_내역_초기화_배선(): assert "showConfirmDialog(" in block and "if (!ok) return;" in block assert "const { bill: _bill, ...rest } = user;" in block assert 'run("/estimation", "PUT"' in block and "{ user: rest }" in block - assert "actions.append(save, reset);" in src + assert "createDock([save, reset]," in src # 303-1 하단 고정 줄 맨 아래 줄 diff --git a/resources/tester/test_185_2_b09_view_bar.py b/resources/tester/test_185_2_b09_view_bar.py index 4aebd6501..e18aa71c4 100644 --- a/resources/tester/test_185_2_b09_view_bar.py +++ b/resources/tester/test_185_2_b09_view_bar.py @@ -1,6 +1,6 @@ -"""185-2 B09 묶음 탭 보기 바꿈 단추 글 한 줄 · 가로 열 수 단추와 같은 줄. +"""185-2 B09 묶음 탭 가로 열 수 단추 한 줄 · 보기 고름 = 좌측 하단(304-1 · 탭 줄에 없음). -- 보기 바꿈 단추 · 가로 열 수 = 도구 묶음(`setTools` · 202-1 로 페이지 보조 탭 줄 오른쪽) · 카드 판 머리 띠 = 묶음 수만 +- 가로 열 수 = 도구 묶음(`setTools` · 202-1 로 페이지 보조 탭 줄 오른쪽) · 카드 판 머리 띠 = 묶음 수만 - 단추 · 라벨 = `white-space: nowrap` · 도구 묶음 = 줄바꿈 없음(`flex-wrap: nowrap`) """ @@ -31,7 +31,7 @@ def _panel(view: str, hint: str): return run( [COMMON, TABS, f"{B09}Labor.ts", GROUPS], f"\nconst spec = buildDaegaBlocks({{ sheets: {{ daega_blocks: [{json.dumps(DG, ensure_ascii=False)}] }} }});" - f"\nchosen.set('dg', '{view}');" + f"\nchooseGroupView('{view}');" "\nconst tools = [];" f"\nconst o = {{ tab: 'dg', spec, hint: {hint}, goto: () => {{}}, redraw: () => {{}}," " setTools: (t) => tools.push(...t)," @@ -39,7 +39,7 @@ def _panel(view: str, hint: str): "\nconst out = renderGroupPanel(o);" "\nconst btn = tools.find((e) => e.dataset.b09GroupView);" "\nconst bar = out[0].all('.ui-group-cards__bar')[0] ?? null;" - "\nconsole.log(JSON.stringify({ n: out.length, first: out[0].className, label: btn.textContent," + "\nconsole.log(JSON.stringify({ n: out.length, first: out[0].className, label: btn ? btn.textContent : null, nTools: tools.length," " cols: tools.flatMap((e) => e.all('[data-group-cols]')).map((b) => b.dataset.groupCols)," " barBtn: bar ? bar.all('[data-b09-group-view]').length : null," " barCols: bar ? bar.all('[data-group-cols]').length : null }));", @@ -49,7 +49,7 @@ def _panel(view: str, hint: str): def test_카드_보기_단추가_열수_단추와_같은_도구_묶음에(): out = _panel("cards", "null") - assert out["label"] == "표로 보기" + assert out["label"] is None # 304-1 보기 단추 = 좌측 하단(탭 줄에 없음) assert out["cols"] == ["1", "2", "3", "4"] # 같은 묶음에 열 수 단추 넷 assert out["barBtn"] == 0 and out["barCols"] == 0 # 카드 판 머리 띠에는 없음 assert out["n"] == 1 # 안내 글 없으면 카드 판 하나뿐 @@ -63,8 +63,8 @@ def test_카드_보기_안내_글은_카드_판_아래에(): def test_표_보기_단추는_도구_묶음에(): out = _panel("table", "null") - assert out["label"] == "박스보기" and out["n"] == 1 - assert out["cols"] == [] # 표 보기 = 열 수 단추 없음 + assert out["label"] is None and out["n"] == 1 + assert out["cols"] == [] and out["nTools"] == 0 # 표 보기 = 도구 없음 def test_CSS_한_줄_nowrap(): @@ -81,4 +81,4 @@ def test_CSS_한_줄_nowrap(): page, ".b09-estimation__tools .ui-btn,\n.b09-estimation__tools .ui-group-cards__label" ) assert "white-space: nowrap" in tools - assert "actions: [toggle]" in (ROOT / GROUPS).read_text("utf-8") + assert "toggle" not in (ROOT / GROUPS).read_text("utf-8") # 304-1 탭 줄 토글 걷음 diff --git a/resources/tester/test_198_1_b08_summary_lamp.py b/resources/tester/test_198_1_b08_summary_lamp.py index 2088141f8..1937ba50a 100644 --- a/resources/tester/test_198_1_b08_summary_lamp.py +++ b/resources/tester/test_198_1_b08_summary_lamp.py @@ -13,7 +13,9 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] TS = ROOT / "config" / "node_modules" / "typescript" -UI = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") +UI = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity" / "B08_Quantity_Summary_Table.ts" +).read_text(encoding="utf-8") PRELUDE = f""" const fs = require("fs"); diff --git a/resources/tester/test_200_1_b09_box_tool_line.py b/resources/tester/test_200_1_b09_box_tool_line.py index e2ac75c0f..203d08e9b 100644 --- a/resources/tester/test_200_1_b09_box_tool_line.py +++ b/resources/tester/test_200_1_b09_box_tool_line.py @@ -36,7 +36,7 @@ def _panel(view: str, hint: str): return run( [COMMON, TABS, f"{B09}Labor.ts", GROUPS], f"\nconst spec = buildDaegaBlocks({{ sheets: {{ daega_blocks: [{json.dumps(DG, ensure_ascii=False)}] }} }});" - f"\nchosen.set('dg', '{view}');" + f"\nchooseGroupView('{view}');" f"\nconst hint = {hint};" "\nconst o = { tab: 'dg', spec, hint, goto: () => {}, redraw: () => {}, setTools: () => {}," " makeTable: () => ({ root: document.createElement('table'), focusRow: () => {} }) };" diff --git a/resources/tester/test_201_1_table_level_merge.py b/resources/tester/test_201_1_table_level_merge.py index c2e564442..5e6f376f9 100644 --- a/resources/tester/test_201_1_table_level_merge.py +++ b/resources/tester/test_201_1_table_level_merge.py @@ -108,10 +108,10 @@ def test_level_보기_전용_들여쓰기(): #: 적용 표 — [파일, 들어 있어야 할 글] APPLIED = [ - ("B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts", 'mergeSame: ["group", "spec"]'), - ("B08_Quantity/B08_Quantity_UI_Material.ts", 'mergeSame: ["name"]'), - ("B08_Quantity/B08_Quantity_UI_Material.ts", 'mergeSame: ["group"]'), - ("B08_Quantity/B08_Quantity_UI_Material.ts", "mergeSame: built.mergeSame"), + ("B08_Quantity/B08_Quantity_Earthwork_Tables.ts", 'mergeSame: ["group", "spec"]'), + ("B08_Quantity/B08_Quantity_Material_Tables.ts", 'mergeSame: ["name"]'), + ("B08_Quantity/B08_Quantity_Material_Tables.ts", 'mergeSame: ["group"]'), + ("B08_Quantity/B08_Quantity_Material_Tables.ts", "mergeSame: built.mergeSame"), ("B09_Estimation/B09_Estimation_UI_Tabs_Base.ts", 'mergeSame: ["part"]'), ("B09_Estimation/B09_Estimation_UI_Tabs_Base.ts", 'level: blockLevel("name"'), ("B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts", 'level: blockLevel("name"'), @@ -120,7 +120,7 @@ APPLIED = [ ("B09_Estimation/B09_Estimation_UI_Tabs_Total.ts", 'mergeSame: ["group", "part"]'), ("B09_Estimation/B09_Estimation_UI_Tabs_Total.ts", 'mergeSame: ["part"]'), ("B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts", 'group: (i) => rows[i]?.kind !== "item"'), - ("B09_Estimation/B09_Estimation_UI_Shell.ts", "mergeSame: spec.mergeSame"), + ("B09_Estimation/B09_Estimation_UI_Builders.ts", "mergeSame: spec.mergeSame"), ] diff --git a/resources/tester/test_202_1_table_hint_bottom.py b/resources/tester/test_202_1_table_hint_bottom.py index 35bacaafc..8cbe24bb4 100644 --- a/resources/tester/test_202_1_table_hint_bottom.py +++ b/resources/tester/test_202_1_table_hint_bottom.py @@ -80,7 +80,7 @@ def test_묶음_탭_단추가_보조_줄에_안내는_아래에(): "\nconst hint = document.createElement('p');" "\nconst res = {};" "\nfor (const view of ['cards', 'table']) {" - "\n chosen.set('dg', view);" + "\n chooseGroupView(view);" "\n bars.draw('dg'); bars.setTools([]);" "\n const panel = renderGroupPanel({ tab: 'dg', spec, hint, goto: () => {}, redraw: () => {}," " makeTable: () => ({ root: document.createElement('table'), focusRow: () => {} })," @@ -95,8 +95,9 @@ def test_묶음_탭_단추가_보조_줄에_안내는_아래에(): "\nconsole.log(JSON.stringify(res));", RAW, ) - assert out["cards"]["inRow"] == ["표로 보기"] and out["cards"]["cols"] == 4 - assert out["table"]["inRow"] == ["박스보기"] and out["table"]["cols"] == 0 + # 304-1 보기 고름 = 좌측 하단 스위치(보조 줄에 없음) · 가로 열 수 = 보조 줄 그대로 + assert out["cards"]["inRow"] == [] and out["cards"]["cols"] == 4 + assert out["table"]["inRow"] == [] and out["table"]["cols"] == 0 for view in ("cards", "table"): assert out[view]["inPanel"] == 0 # 판 안에는 단추 없음 assert out[view]["hintLast"] and out[view]["n"] == 2 # 판 + 안내 줄(맨 아래) diff --git a/resources/tester/test_212_b07_cross_station_layers.py b/resources/tester/test_212_b07_cross_station_layers.py index baa293a31..b875abb81 100644 --- a/resources/tester/test_212_b07_cross_station_layers.py +++ b/resources/tester/test_212_b07_cross_station_layers.py @@ -175,7 +175,8 @@ def test_옛_확정_장_원지반_잠금_풂_측점_층_안내(tmp_path: Path): def test_화면_덧그림_측점_층_design_ids(): - struct = (ROOT / "B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts").read_text( + # 얹는 몸통 = Cross_Overlay.placeStructureEntities(302-2 · 화면 · 인쇄 Node 한 벌) + struct = (ROOT / "B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts").read_text( encoding="utf-8" ) assert "layer_id?: string;" in struct and "design_ids?: string[];" in struct diff --git a/resources/tester/test_235_1_b09_bill_tree.py b/resources/tester/test_235_1_b09_bill_tree.py index 46b549af0..72f567665 100644 --- a/resources/tester/test_235_1_b09_bill_tree.py +++ b/resources/tester/test_235_1_b09_bill_tree.py @@ -345,7 +345,8 @@ def test_고른_줄_상자_테두리_테마_토큰(): def test_Shell_배선(): shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") - assert "editOnDouble: spec.editOnDouble ?? true," in shell # 243-1 기본 = 더블클릭 + builders = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Builders.ts").read_text("utf-8") + assert "editOnDouble: spec.editOnDouble ?? true," in builders # 243-1 기본 = 더블클릭 assert "levelBar(table.root, bill, (i) => table.edit(i))" in shell for name in [ "ui_template/ui_template_data_table.ts", diff --git a/resources/tester/test_236_1_hint_one_line.py b/resources/tester/test_236_1_hint_one_line.py index edd8941c1..1e7ba7e56 100644 --- a/resources/tester/test_236_1_hint_one_line.py +++ b/resources/tester/test_236_1_hint_one_line.py @@ -138,7 +138,9 @@ def test_B08_표_아래_안내는_footNote만(): summary = _read(B08 / "B08_Quantity_UI_Summary.ts") assert 'footNote(summary.notes.join(" · "))' in summary # 서버 알림 = 한 줄로 이어 붙임 sheets = _read(B08 / "B08_Quantity_UI_Earthwork_Sheets.ts") - assert "tableCard(haulNote(table), grid.root, averages)" in sheets # 제작 설명 줄 없음 + assert ( + "tableCard(haulNote(table), grid.root, averages.map(note))" in sheets + ) # 제작 설명 줄 없음 # ── 창 끝까지 셈 ──────────────────────────────────────────────────────────── diff --git a/resources/tester/test_243_1_double_click_tables.py b/resources/tester/test_243_1_double_click_tables.py index a261089b1..f03bdb57e 100644 --- a/resources/tester/test_243_1_double_click_tables.py +++ b/resources/tester/test_243_1_double_click_tables.py @@ -112,10 +112,13 @@ def test_B09_표_만드는_길_한_곳_더블클릭_기본(): calls = [ p.name for p in _sources("B09_Estimation") if "createDataTable({" in p.read_text("utf-8") ] - assert calls == ["B09_Estimation_UI_Shell.ts"] - shell = (ROOT / "B09_Estimation" / calls[0]).read_text("utf-8") - assert "editOnDouble: spec.editOnDouble ?? true," in shell - assert "keyEdit: !(spec as BillTable).menu," in shell # 설계내역서 Enter · F2 = 레벨 키 몫 + # 303-2 팝업 창 = 보기 전용(고칠 칸 없음) · 옵션 = 같은 표 짜기(Builders) + assert calls == ["B09_Estimation_UI_Popup.ts", "B09_Estimation_UI_Shell.ts"] + shell = (ROOT / "B09_Estimation" / calls[1]).read_text("utf-8") + assert "...tableOptionsOf(spec, tab)," in shell # 303-2 옵션 짜기 = Builders 한 곳 + builders = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Builders.ts").read_text("utf-8") + assert "editOnDouble: spec.editOnDouble ?? true," in builders + assert "keyEdit: !(spec as BillTable).menu," in builders # 설계내역서 Enter · F2 = 레벨 키 몫 def test_B08_고칠_칸_있는_표_모두_editOnDouble(): @@ -133,5 +136,6 @@ def test_B08_고칠_칸_있는_표_모두_editOnDouble(): def test_고르기_메뉴_이동_그대로(): shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") - assert "if (spec.link && !spec.editable?.(i, key)) goto(spec.link(i, key));" in shell + assert "if (spec.link && !spec.editable?.(i, key))" in shell # 303-2 누른 줄 = 팝업 자리 + assert 'goto(spec.link(i, key), made.root.querySelector(`tr[data-i="${i}"]`));' in shell assert "spec.choices?.(Number(cell.parentElement?.dataset.i), column.key)" in shell diff --git a/resources/tester/test_244_13_b08_milestone_place_leg.py b/resources/tester/test_244_13_b08_milestone_place_leg.py index e462cbf04..ea87c7a73 100644 --- a/resources/tester/test_244_13_b08_milestone_place_leg.py +++ b/resources/tester/test_244_13_b08_milestone_place_leg.py @@ -120,7 +120,7 @@ console.log(JSON.stringify([["g", "d", "b1", "start"].map((f) => PAVED[segKind(D def test_표_열_기준점_따라(): got = _node( - [B08 + "UI_Milestone.ts"], + [B08 + "Milestone_Table.ts"], "console.log(JSON.stringify([[false, false], [true, false], [true, true]].map(([b, p]) =>" " milestoneColumns(b, p).map((c) => `${(c.group ?? [''])[0]}|${c.label}|${c.key}`))));", ) diff --git a/resources/tester/test_244_1_b08_milestone.py b/resources/tester/test_244_1_b08_milestone.py index d7013901c..6ee6d9d0f 100644 --- a/resources/tester/test_244_1_b08_milestone.py +++ b/resources/tester/test_244_1_b08_milestone.py @@ -166,7 +166,10 @@ def test_메인_탭_하위_탭_추가_삭제(): assert 'label: "+ 추가"' not in ui and "removeCurrent" not in ui modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") assert "fresh.routes = fresh.routes.filter((r) => r.id !== route!.id);" in modal - assert "editOnDouble: true" in ui and "computeMilestone(data, siteInfo()" in ui + assert "editOnDouble: true" in ui and "milestoneTable(data, siteInfo()" in ui + assert "computeMilestone(data, midDefaultM)" in _read( + "B08_Quantity/B08_Quantity_Milestone_Table.ts" + ) route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts") assert "createDataTable" not in route and "uploadImage(projectId, routeId" in route # 조작 = 캐시 · [저장] = 좌측 패널 한 줄에서 PUT · 자동 저장 없음 diff --git a/resources/tester/test_244_6_b08_milestone_pan_client.py b/resources/tester/test_244_6_b08_milestone_pan_client.py index cef51aaa2..a7844fee8 100644 --- a/resources/tester/test_244_6_b08_milestone_pan_client.py +++ b/resources/tester/test_244_6_b08_milestone_pan_client.py @@ -136,6 +136,7 @@ def test_화면_글_거래처(): modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") assert 'label: "거래처 이름"' in modal page = _read("B08_Quantity/B08_Quantity_UI_Milestone.ts") - assert '{ key: "company", label: "거래처 이름"' in page and "`거래처 ${" in page + table = _read("B08_Quantity/B08_Quantity_Milestone_Table.ts") + assert '{ key: "company", label: "거래처 이름"' in table and "`거래처 ${" in page for name in UI: assert len(_read(f"B08_Quantity/B08_Quantity_{name}").splitlines()) <= 700 diff --git a/resources/tester/test_248_1_b09_group_jump_unpick.py b/resources/tester/test_248_1_b09_group_jump_unpick.py index 0ebb03c45..5c555a3b9 100644 --- a/resources/tester/test_248_1_b09_group_jump_unpick.py +++ b/resources/tester/test_248_1_b09_group_jump_unpick.py @@ -60,7 +60,7 @@ def test_표_빨강_하나_누름_키_풂(): " return { root, focusRow: (i, mark) => scrolled.push([i, mark]) }; };" "\nconst o = { tab: 'dg', spec, hint: null, focus: 'B|1', goto: () => {}, redraw: () => {}," " setTools: () => {}, makeTable };" - "\nchosen.set('dg', 'table');" + "\nchooseGroupView('table');" "\nconst marks = () => [root.children.map((tr) => tr.className || null)," " spec.rows.map((_, i) => extra(i))];" "\nconst out = {};" diff --git a/resources/tester/test_264_2_b09_schedule_options.py b/resources/tester/test_264_2_b09_schedule_options.py index 2266968f5..93163e72f 100644 --- a/resources/tester/test_264_2_b09_schedule_options.py +++ b/resources/tester/test_264_2_b09_schedule_options.py @@ -205,6 +205,6 @@ def test_패널_훅_draft_없으면_빈칸(): tabs = (ROOT / "B09_Estimation/B09_Estimation_UI_Tabs.ts").read_text("utf-8") shell = (ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts").read_text("utf-8") ui = (ROOT / "B09_Estimation/draft/B09_Draft_Schedule_UI.ts").read_text("utf-8") - assert "{ builders: {}, extend: [], panels: [] }" in tabs # draft 없으면 카드 없음 + assert "{ builders: {}, extend: [], panels: [], actions: [] }" in tabs # draft 없으면 카드 없음 assert "...DRAFT.panels.map((make) => make(() => user, setUser))" in shell assert "DRAFT.panels.push(" in ui diff --git a/resources/tester/test_287_ford_pavement_followup.py b/resources/tester/test_287_ford_pavement_followup.py index 80a7a70c8..01700dfde 100644 --- a/resources/tester/test_287_ford_pavement_followup.py +++ b/resources/tester/test_287_ford_pavement_followup.py @@ -1,8 +1,8 @@ """287 물넘이포장 후속(요구 183-2 · 4 · 6 · 7 · 8 · 9). 못박는 것 - - 287-1 도수로/돌붙임 길이 ↔ 면적 — 길이 넣음 = 월류 폭 × 길이 · 비움 = 칸 기본값 · 월류 폭을 바꾸면 - 길이가 있을 때만 다시 셈 · 폭이 비면 물넘이 기본 폭. + - 301-1 도수로/돌붙임 면적 = 늘 손 입력(요구 192-2 · 287-1 자동 셈 걷음) — 길이 · 월류 폭을 바꾸거나 + 길이를 비워도 면적 그대로 · 길이 = 그림 전용(294-4). - 287-3 월류 폭 → 높이 자동 = 세월교 한 벌(`fordHeightM` · 폼 `overflow(…, "slope")`) · 유량 없으면 손 입력. - 287-5 물넘이포장 형상 누름 = 다른 시설과 같은 고름 창구(`toggleRevet("pipe")` → 폼 · 알약 · 측점선) · 판정면은 보라 손잡이 아래(요구 185 ①) · 끌기(3 px 넘게 움직임) = 고름 아님(②) · 노면공 포장층 @@ -51,17 +51,19 @@ def ts() -> dict: return json.loads(proc.stdout) -def test_도수로_길이_면적(ts): - assert ts["areaByLength"] == "24" # 6 m × 4 m - assert ts["areaCleared"] == "30" # 길이 비움 = 기본값 - assert ts["areaByWidth"] == "20" # 폭 8 m × 2.5 m - assert ts["areaWidthNoLength"] == "17" # 길이 없으면 폭을 바꿔도 그대로 - assert ts["areaNoWidth"] == "30" # 폭 비움 = 기본 폭 10 m × 3 m +def test_도수로_면적은_손_입력_그대로(ts): + # 길이 4 넣음 · 월류 폭 8 · 길이 비움 — 면적은 손 입력 17 그대로 + assert ts["areaKeptPave"] == ["17", "17", "17"] + assert ts["areaKeptFord"] == ["17", "17", "17"] -def test_물넘이_폼이_길이_면적을_잇는다(): +def test_폼이_길이_면적을_잇지_않는다(): form = _read("B05_Profile", "B05_Profile_UI_Drainage_Facility.ts") - assert "syncChuteArea(paveForm, c, item), paveFlowBox.onChange(c, item)" in form + assert "syncChuteArea" not in form + assert "onChange: fordFlowBox.onChange," in form + assert "onChange: paveFlowBox.onChange," in form + calc = _read("A00_Common", "design", "design_structure_ford_calc.ts") + assert "syncChuteArea" not in calc and "chuteAreaM2" not in calc def _pts(text: str) -> list[float]: diff --git a/resources/tester/test_294_3_ford_extend_chute.py b/resources/tester/test_294_3_ford_extend_chute.py index e20bb8560..4d0d4a9a7 100644 --- a/resources/tester/test_294_3_ford_extend_chute.py +++ b/resources/tester/test_294_3_ford_extend_chute.py @@ -7,8 +7,8 @@ 연장」) · 그림 = 콘크리트(포장층)가 늘인 끝까지. - 294-4 도수로/돌붙임 띠 = 낮은 쪽 끝단(늘인 끝 · 아니면 유출 쪽 노견 끝)에서 성토 사면 따라 · 길이 비움 = 원지반 닿으면 끝 · 양측 절토 물넘이 = 띠 없음 · 길이 = 그 길이(사면보다 길면 원지반 따라) · 다른 구조물 - (배관 · 박스 · 세월교) 같은 규칙(`chuteRun` · 원지반 `layout.ground`) + 같은 「길이 (m)」 칸 · 면적 = 세월교만 - 월류 폭 × 길이(배관 · 박스 = 폭 정의 없어 자동 안 함) · 길이는 집계 · B08 수량에 안 실림. + (배관 · 박스 · 세월교) 같은 규칙(`chuteRun` · 원지반 `layout.ground`) + 같은 「길이 (m)」 칸 · 면적 = 늘 + 손 입력(301-1 · 길이 · 폭으로 안 셈) · 길이는 집계 · B08 수량에 안 실림. 진짜 TS 는 `helper_294_3_ford_extend_chute.cjs` 가 Node 로 돌림. 화면은 사용자 확인. """ @@ -155,7 +155,6 @@ def test_성토_쪽_띠는_원지반_닿으면_끝(ts): def test_다른_구조물_같은_규칙과_길이_칸(ts): assert ts["chuteItems"][-1] == "length_m" assert ts["fordPaveChuteSame"] is True - assert ts["fordArea"] == "20" # 세월교 월류 폭 8 × 길이 2.5 hatch = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Wall_Hatch.ts").read_text( encoding="utf-8" ) @@ -168,7 +167,7 @@ def test_다른_구조물_같은_규칙과_길이_칸(ts): form = (ROOT / "B05_Profile" / "B05_Profile_UI_Drainage_Facility.ts").read_text( encoding="utf-8" ) - assert form.count("syncChuteArea(") == 2 # 물넘이 · 세월교만(배관 · 박스 = 폭 정의 없음) + assert "syncChuteArea" not in form # 면적 = 손 입력(301-1) def test_길이는_집계_수량에_안_실림(): diff --git a/resources/tester/test_302_1_print_frame.py b/resources/tester/test_302_1_print_frame.py new file mode 100644 index 000000000..5c7b89347 --- /dev/null +++ b/resources/tester/test_302_1_print_frame.py @@ -0,0 +1,442 @@ +"""PLAN 302-1 — 인쇄 미리보기 틀(초안 길 등록 · 서버 표 PDF · 엑셀 · 표 → 서버 이음매).""" + +import io +import json +import re +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest +from fastapi import APIRouter, FastAPI +from fastapi.testclient import TestClient + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B09_Estimation import B09_Estimation_Router as b09 # noqa: E402 +from B09_Estimation.draft import B09_Draft_Print_Router as pr # noqa: E402 + +DRAFT = ROOT / "B09_Estimation/draft" +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) +PID = "00000000-0000-0000-0000-000000000001" + + +def _text(path: Path) -> str: + return path.read_text("utf-8") + + +def _client() -> TestClient: + app = FastAPI() + app.include_router(pr.router, prefix="/api/projects") + return TestClient(app) + + +def _ts(expr: str) -> object: + """표 짜기(공용 표 + 인쇄 표) TS 를 노드로 — expr 결과 JSON.""" + js = "".join( + IMPORTS.sub("", _text(p)) + for p in ( + ROOT / "ui_template/ui_template_data_table.ts", + DRAFT / "B09_Draft_Print_Table.ts", + ) + ) + run = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"], + input=js + f"\nconsole.log(JSON.stringify({expr}));", + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert run.returncode == 0, run.stderr + return json.loads(run.stdout) + + +#: 머리 두 줄(묶음) · 숫자 열 · 레벨 · 세로 병합 · 합계 · 제목 줄 +TABLE = """{ + columns: [ + { key: "kind", label: "공종" }, + { key: "name", label: "이름", width: 120 }, + { key: "q", label: "수량", group: ["물량"], kind: "number", decimals: 2 }, + { key: "a", label: "금액", group: ["물량"], kind: "number" }, + ], + rows: [ + { kind: "토공", name: "토공", q: null, a: null }, + { kind: "토공", name: "터파기", q: 12.5, a: 1500 }, + { kind: "토공", name: "되메우기 ", q: 3, a: 300 }, + { kind: "포장", name: "포장", q: 1234.567, a: 2000000 }, + ], + total: { name: "합계", a: 2001800 }, + frozen: 1, + mergeSame: ["kind"], + level: { key: "name", of: (i) => (i === 0 ? 1 : 2) }, + rowClass: (i) => (i === 0 ? "is-title" : null), + onRowClick: () => {}, +}""" + + +def _sheets(): + return _ts( + f"(() => {{ const t = {TABLE}; return [" + "{name: '갑 / 수량[1]', orientation: 'portrait', html: sheetHtml({id:'a', name:'갑', table:t})," # noqa: E501 + " grid: sheetGrid(t)}," + "{name: '을: 원가표가 아주 아주 길어 서른한 자를 넘는 이름입니다만', orientation: 'landscape'," # noqa: E501 + " html: sheetHtml({id:'b', name:'을', table:t, images:[{src:'data:image/png;base64,iVBORw0KGgo='," # noqa: E501 + " caption:'그림 <1>'}]}), grid: sheetGrid(t)}]; })()" + ) + + +# ── 길 등록 ────────────────────────────────────────────────────────────── + + +def test_초안_길은_선언으로_등록_정적_표엔_이름_없음(): + router = _text(ROOT / "A00_Common/router.ts") + assert ( + 'import.meta.glob<{ ROUTE: DraftRoute }>("../*/draft/*_Route.ts", { eager: true })' + in router + ) + assert "routeTable[route] ?? draft?.load" in router + assert "draft?.workflow" in router and "draft?.project" in router + assert "draftRoute(currentRoute())?.footerless" in _text(ROOT / "A00_Common/main.ts") + route = _text(DRAFT / "B09_Draft_Print_Route.ts") + assert 'path: "b09-print"' in route and "workflow: true" in route and "project: true" in route + # 가짜 대조 — 정적 표 · 셸 · 문구에 초안 이름이 없음(폴더째 지우면 길이 함께 사라짐) + for rel in ( + "config/config_frontend.ts", "A00_Common/router.ts", "A00_Common/main.ts", + "ui_template/ui_template_loupe.ts", "B09_Estimation/B09_Estimation_UI_Shell.ts", + "B09_Estimation/B09_Estimation_UI_Tabs.ts", + ): # fmt: skip + body = _text(ROOT / rel) + assert "b09-print" not in body and "Draft_Print" not in body, rel + # 돋보기 · 기본 메뉴 막음 = 주소 꼴(b0x-)로 함께 걸림 + assert re.search(r"/\^\(b0\[1-9\]", _text(ROOT / "ui_template/ui_template_loupe.ts")) + + +def test_서버_초안_길_끼우기_폴더_없으면_길_없음(tmp_path, monkeypatch): + paths = {r.path for r in b09.router.routes} + assert "/api/projects/{project_id}/print/table-pdf" in paths + assert "/api/projects/{project_id}/print/table-xlsx" in paths + # 가짜 폴더 — 비면 아무것도 안 끼움 · *_Router.py 가 있으면 그 길만 · 깨진 초안은 건너뜀 + pkg = tmp_path / "fakepkg" + (pkg / "draft").mkdir(parents=True) + (pkg / "__init__.py").write_text("") + (pkg / "draft/__init__.py").write_text("") + monkeypatch.syspath_prepend(str(tmp_path)) + empty = APIRouter() + assert b09.include_drafts(empty, pkg / "draft", "fakepkg.draft") == [] + assert empty.routes == [] + (pkg / "draft/X_Router.py").write_text( + "from fastapi import APIRouter\nrouter = APIRouter()\n" + "@router.get('/x')\ndef x():\n return 1\n" + ) + (pkg / "draft/Y_Router.py").write_text("import 없는_모듈\n") + got = APIRouter() + assert b09.include_drafts(got, pkg / "draft", "fakepkg.draft") == ["X_Router"] + assert [r.path for r in got.routes] == ["/x"] + # 초안이 같은 머리를 스스로 달았으면 한 번만(도면 302-2 꼴) + (pkg / "draft/Z_Router.py").write_text( + "from fastapi import APIRouter\nrouter = APIRouter(prefix='/api/projects')\n" + "@router.get('/{project_id}/z')\ndef z():\n return 1\n" + ) + head = APIRouter(prefix="/api/projects") + assert b09.include_drafts(head, pkg / "draft", "fakepkg.draft") == ["X_Router", "Z_Router"] + assert {r.path for r in head.routes} == {"/api/projects/x", "/api/projects/{project_id}/z"} + assert [r.path for r in sys.modules["fakepkg.draft.Z_Router"].router.routes] == [ + "/api/projects/{project_id}/z" + ] # 원본 그대로(앱에 바로 붙이는 시험) + assert ( + "/api/projects/{project_id}/print/drawings-pdf" in paths + or not (DRAFT / "B09_Draft_Print_Drawings_Router.py").exists() + ) + + +def test_로그인_회사_권한은_B09_길과_같음(): + main = _text(ROOT / "main.py") + assert "app.include_router(b09_estimation_router, dependencies=protected_with_company)" in main + assert "Depends(require_project_access)" in main + # 초안 router 는 머리 · 의존성 없이 B09 router 안으로만 들어감 + assert "APIRouter()" in _text(DRAFT / "B09_Draft_Print_Router.py") + + +# ── 서버 표 출력 ────────────────────────────────────────────────────────── + + +def test_table_pdf_쪽_방향_시트_이름_쪽_번호(): + pymupdf = pytest.importorskip("pymupdf") + sheets = _sheets() + res = _client().post( + f"/api/projects/{PID}/print/table-pdf", + json={"title": "수량산출", "sheets": [{k: s[k] for k in ("name", "orientation", "html")} + for s in sheets]}, + ) # fmt: skip + if res.status_code == 503: + pytest.skip(res.json()["detail"]) # 서버에 Edge · Chrome 없음 + assert res.status_code == 200, res.text + assert res.content[:5] == b"%PDF-" and res.headers["content-type"] == "application/pdf" + doc = pymupdf.open(stream=res.content) + assert doc.page_count == 2 + p1, p2 = doc[0], doc[1] + assert p1.rect.width < p1.rect.height and p2.rect.width > p2.rect.height # 시트마다 방향 + t1, t2 = p1.get_text(), p2.get_text() + assert "갑 / 수량[1]" in t1 and "을: 원가표" in t2 # 쪽 머리 = 시트 이름 + assert "1 / 2" in t1 and "2 / 2" in t2 # 쪽 번호 + assert "되메우기 " in t1 and "1,234.57" in t1 and "2,001,800" in t1 # 화면과 같은 글 + assert "그림 <1>" in t2 + + +def test_table_pdf_넓은_표는_쪽_폭에_맞춤_긴_표는_머리_되풀이(): + pymupdf = pytest.importorskip("pymupdf") + rows = "".join(f"줄{i}{i}" for i in range(120)) + html = ( + '' + '' + f"{rows}
이름값
" + ) + doc = pr.document_html("t", [{"name": "넓은 표", "orientation": "portrait", "html": html}]) + try: + data = pr.spawn_pdf(doc) + except pr.PrintError as error: + pytest.skip(str(error)) + pdf = pymupdf.open(stream=data) + words = [w for p in pdf for w in p.get_text("words")] + assert max(w[2] for w in words) <= pdf[0].rect.width # 오른쪽이 쪽 밖으로 안 나감 + texts = [p.get_text() for p in pdf] + assert "줄119" in texts[-1] + assert all("넓은 표" in t and "이름" in t for t in texts) # 쪽마다 머리 + + +def test_table_pdf_바깥_요청_막음(): + html = '
' + doc = pr.document_html("t", [{"name": "s", "orientation": "portrait", "html": html}]) + assert " None: + """엑셀 인쇄 설정(302-5) — 시트 방향 = PDF · A4 · 폭 1쪽 · 높이 자유 · 머리 줄 반복.""" + assert len(wb.worksheets) == len(orientations) + for ws, orient, head in zip(wb.worksheets, orientations, heads): + ps = ws.page_setup + assert ps.orientation == orient, ws.title + assert int(ps.paperSize) == 9 # A4 + assert (int(ps.fitToWidth), int(ps.fitToHeight)) == (1, 0), ws.title + assert ws.sheet_properties.pageSetUpPr.fitToPage is True + assert ws.print_title_rows.replace("$", "") == f"1:{head}", ws.title + assert ws.page_margins.left < 0.5 and ws.page_margins.bottom < 0.6 + + +def test_table_xlsx_시트_차례_이름_머리_병합_숫자_칸(): + from openpyxl import load_workbook + + sheets = _sheets() + keys = ("name", "orientation", "grid") + body = {"title": "수량", "sheets": [{k: s[k] for k in keys} for s in sheets]} + body["sheets"].append({"name": "갑 _ 수량_1_", "grid": sheets[0]["grid"]}) # 같은 이름 + res = _client().post(f"/api/projects/{PID}/print/table-xlsx", json=body) + assert res.status_code == 200, res.text + wb = load_workbook(io.BytesIO(res.content)) + _assert_print(wb, [*[s["orientation"] for s in sheets], "portrait"], [2, 2, 2]) + names = wb.sheetnames + assert names[0] == "갑 _ 수량_1_" # 금지 글자 = _ + assert ( + names[1] == "을_ 원가표가 아주 아주 길어 서른한 자를 넘는 이름입니다만"[:31] + and len(names[1]) == 31 + ) + assert names[2] == "갑 _ 수량_1_ (2)" + ws = wb[names[0]] + merged = {str(m) for m in ws.merged_cells.ranges} + # 머리 두 줄 — 「물량」 = 옆 둘 · 공종 · 이름 = 아래 둘 / 본문 공종 「토공」 세 줄 세로 병합 + assert {"C1:D1", "A1:A2", "B1:B2", "A3:A5"} <= merged + assert (ws["C1"].value, ws["C2"].value, ws["A1"].value) == ("물량", "수량", "공종") + assert ws["A1"].font.b and ws["C2"].font.b # 머리 굵게 + assert ( + ws["C4"].value == 12.5 + and ws["C4"].data_type == "n" + and ws["C4"].number_format == "#,##0.00" + ) + assert ws["D6"].value == 2000000 and ws["D6"].number_format == "#,##0" + assert ws["B5"].value == "되메우기 " and ws["C3"].value is None + assert ws["B4"].alignment.indent == 1 and ws["B3"].font.b # 레벨 2 들여씀 · 제목 줄 굵게 + assert ( + ws["B7"].value == "합계" and ws["D7"].value == 2001800 and ws["D7"].font.b + ) # 합계 = 끝 줄 + assert ws["A3"].border.left.style == "thin" + assert ws.freeze_panes == "B3" + + +def test_table_xlsx_꼴_틀리면_400(): + res = _client().post( + f"/api/projects/{PID}/print/table-xlsx", json={"sheets": [{"name": "a", "grid": {}}]} + ) + assert res.status_code == 400 + res = _client().post(f"/api/projects/{PID}/print/table-pdf", json={"sheets": []}) + assert res.status_code == 422 + + +def test_시트_이름_다듬기(): + used: set[str] = set() + assert pr.sheet_title("a/b\\c?d*e[f]g:h", used) == "a_b_c_d_e_f_g_h" + assert pr.sheet_title("", used) == "시트" + assert pr.sheet_title("A_B_C_D_E_F_G_H", used) == "A_B_C_D_E_F_G_H (2)" # 대소문자 같은 이름 + + +# ── 영역 이음매 — 원가계산(302-4) 쪽 → 페이지와 같은 서버 몸 → 표 PDF · 엑셀 ──────────── + +ENTRY = ROOT / "resources/tester/print_frame/print_frame_entry.ts" +VITE = ROOT / "config/node_modules/vite/bin/vite.js" + + +@pytest.fixture(scope="module") +def cost_bodies(tmp_path_factory) -> dict: + sys.path.insert(0, str(ROOT / "resources/tester")) + import test_302_4_print_cost as t302_4 + + if shutil.which("node") is None: + pytest.skip("node 가 없음") + work = tmp_path_factory.mktemp("p302_1") + root = t302_4.t145._project(work / "proj", [(100, "SS000901"), (400, "SS000902")]) + data = work / "payload.json" + data.write_text(json.dumps(t302_4._payload(root), ensure_ascii=False, default=str), "utf-8") + out = work / "build" + entry_rel = "../" + str(ENTRY.relative_to(ROOT)).replace("\\", "/") + build = subprocess.run( + ["node", str(VITE), "build", "--configLoader", "runner", "--ssr", entry_rel, + "--outDir", str(out)], + cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8", errors="replace", + timeout=180, + ) # fmt: skip + assert build.returncode == 0, f"vite ssr 번들 실패:\n{build.stdout}\n{build.stderr}" + run = subprocess.run( + ["node", str(out / "print_frame_entry.js"), str(data)], + capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=60, + ) # fmt: skip + assert run.stdout.strip(), run.stderr + return json.loads(run.stdout) + + +def test_원가_영역_엑셀_18장_차례_그대로(cost_bodies): + from openpyxl import load_workbook + + body = cost_bodies["xlsx"] + assert len(body["sheets"]) == 18 + res = _client().post(f"/api/projects/{PID}/print/table-xlsx", json=body) + assert res.status_code == 200, res.text + wb = load_workbook(io.BytesIO(res.content)) + assert wb.sheetnames == [s["name"] for s in body["sheets"]] # 이름 31자 안 · 차례 그대로 + first = wb[wb.sheetnames[0]] + assert first.max_row > body["sheets"][0]["grid"]["head_rows"] # 값 줄이 들어감 + assert any(c.data_type == "n" for row in first.iter_rows() for c in row if c.value is not None) + _assert_print( # 엑셀 인쇄 방향 = 그 시트 PDF 방향(302-5) + wb, + [s["orientation"] for s in cost_bodies["pdf"]["sheets"]], + [s["grid"]["head_rows"] for s in body["sheets"]], + ) + + +def test_원가_영역_PDF_시트_이름_가로(cost_bodies): + pymupdf = pytest.importorskip("pymupdf") + body = cost_bodies["pdf"] + assert all(s["orientation"] == "landscape" and "= 18 + text = "".join(p.get_text() for p in doc) + assert all(s["name"] in text for s in body["sheets"]) + assert all(p.rect.width > p.rect.height for p in doc) + + +def test_앱_전체_길_목록에_겹친_앞머리_없음(): + import main + + paths = [getattr(r, "path", "") for r in main.app.routes] + assert not [p for p in paths if "/api/projects/api/projects" in p] + for tail in ("drawings", "drawings-pdf", "table-pdf", "table-xlsx"): + assert paths.count(f"/api/projects/{{project_id}}/print/{tail}") == 1, tail + + +# ── 영역 이음매 — 수량산출(302-3) 쪽 → 페이지와 같은 서버 몸 → 표 PDF · 엑셀 ──────────── + +#: 1×1 PNG — 거리이정표 지도 그림 자리 +TINY_PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" # noqa: E501 + + +@pytest.fixture(scope="module") +def quantity_bodies() -> dict: + sys.path.insert(0, str(ROOT / "resources/tester")) + import test_302_3_print_quantity as t302_3 + + if shutil.which("node") is None: + pytest.skip("node 가 없음") + job = {"project": "p302", "responses": t302_3._responses(), "image_b64": TINY_PNG} + out = subprocess.run( + ["node", str(ROOT / "resources/tester/print_frame/print_quantity_seam.mjs"), str(ROOT)], + input=json.dumps(job, ensure_ascii=False), cwd=ROOT, capture_output=True, text=True, + encoding="utf-8", timeout=300, + ) # fmt: skip + assert out.returncode == 0, out.stderr + return json.loads(out.stdout.strip().splitlines()[-1]) + + +def test_수량_영역_엑셀_차례_그대로(quantity_bodies): + from openpyxl import load_workbook + + body = quantity_bodies["xlsx"] + assert len(body["sheets"]) >= 20 + res = _client().post(f"/api/projects/{PID}/print/table-xlsx", json=body) + assert res.status_code == 200, res.text + wb = load_workbook(io.BytesIO(res.content)) + used: set[str] = set() + assert wb.sheetnames == [pr.sheet_title(s["name"], used) for s in body["sheets"]] + assert all( + wb[n].max_row >= s["grid"]["head_rows"] for n, s in zip(wb.sheetnames, body["sheets"]) + ) + _assert_print( + wb, + [s["orientation"] for s in quantity_bodies["pdf"]["sheets"]], + [s["grid"]["head_rows"] for s in body["sheets"]], + ) + + +def test_수량_영역_PDF_시트_이름_지도_그림(quantity_bodies): + pymupdf = pytest.importorskip("pymupdf") + body = quantity_bodies["pdf"] + assert any('src="data:image/png' in s["html"] for s in body["sheets"]) # 그림 = data: 주소 + res = _client().post(f"/api/projects/{PID}/print/table-pdf", json=body) + if res.status_code == 503: + pytest.skip(res.json()["detail"]) + assert res.status_code == 200, res.text + doc = pymupdf.open(stream=res.content) + assert doc.page_count >= len(body["sheets"]) + text = "".join(p.get_text() for p in doc).replace("\n", " ") + missing = [s["name"] for s in body["sheets"] if s["name"] not in text] + assert not missing + assert sum(len(p.get_images()) for p in doc) >= 1 # 거리이정표 지도 그림이 들어감 + + +def test_B09_좌측_하단_단추는_초안_입구에서만(): + ui = _text(DRAFT / "B09_Draft_Print_UI.ts") + assert "DRAFT.actions.push(" in ui and "navigateTo(ROUTE.path" in ui + assert 'label: "인쇄 미리보기"' in ui + shell = _text(ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts") + assert 'import.meta.glob("./draft/*_UI.ts", { eager: true })' in shell + dock = _text(ROOT / "B09_Estimation/B09_Estimation_UI_Dock.ts") + assert "DRAFT.actions.map((make) => make())" in dock and "Print" not in dock + + +def test_좌측_하단_두_줄_가로_넘침_없음(): + """302-5 — 위 = [PDF][엑셀] · 아래 = [?](도움말 줄) [B09 로 돌아가기] · 줄 폭을 나눠 가짐.""" + page = _text(DRAFT / "B09_Draft_Print_Page.ts") + assert "files.append(pdfBtn, xlsxBtn)" in page + assert "nav.dataset.helpRow" in page and "nav.append(back)" in page + assert "actions.append(files, nav)" in page + css = re.sub(r"\s+", " ", _text(DRAFT / "B09_Draft_Print_Page.css")) + assert ".b09-print__actions.ui-sidebar-actions { flex-direction: column; }" in css + btn = css[css.index(".ui-sidebar-actions.b09-print__actions .b09-print__row > .ui-btn") :] + btn = btn[: btn.index("}")] + # 공용 단추 줄(min-width: auto = 글보다 안 좁아짐)보다 앞서 이기는 규칙 · 글 줄임표 + assert "flex: 1 1 0" in btn and "min-width: 0" in btn and "text-overflow: ellipsis" in btn diff --git a/resources/tester/test_302_2_print_drawings.py b/resources/tester/test_302_2_print_drawings.py new file mode 100644 index 000000000..27e722b59 --- /dev/null +++ b/resources/tester/test_302_2_print_drawings.py @@ -0,0 +1,273 @@ +"""PLAN 302-2 인쇄 미리보기 도면 PDF — 목록 차례 · 쪽 수 · 쪽 크기 · 한글 · 숨긴 층 · 권한. + +임시 폴더 견본만 씀(공용 storage 안 씀) — B07 목록 · 단건 · 표제란 값은 바꿔 끼움. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from uuid import uuid4 + +import pymupdf +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( + frame_entities, + usable_bbox, + use_project_templates, + use_title_fields, +) +from B09_Estimation.draft import B09_Draft_Print_Drawings_Router as router_module +from B09_Estimation.draft import B09_Draft_Print_Drawings_Source as source +from B09_Estimation.draft.B09_Draft_Print_Drawings_Pdf import ( + PT_PER_MM, + add_drawing_page, + paint_color, +) +from common_util import common_util_auth as auth + +TITLE = {"공사명": "가나다임도개설공사", "용역회사": "라마바설계"} + + +def _text(label: str, x: float, y: float, layer: str) -> dict: + return { + "id": str(uuid4()), + "type": "Text", + "lineColor": "#ffffff", + "lineWidth": 1, + "layerId": layer, + "shapeData": { + "basePoint": {"x": x, "y": y}, + "label": label, + "options": {"fontSize": 10, "textAlign": "left", "textColor": "#ffffff"}, + }, + } + + +def _b07_drawing(drawing_id: str) -> dict: + """도각(시스템 A1 · 표제란 = TITLE) + 보이는 글 + 숨긴 층 · 동결 층 글.""" + use_project_templates(None) + use_title_fields(TITLE) + entities = frame_entities(drawing_id, usable_bbox(), fit=False, fields={"도면명": "종단면도"}) + entities += [ + _text("보이는글", 200, 300, "draw"), + _text("숨은글", 200, 320, "hidden"), + _text("얼린글", 200, 340, "frozen"), + ] + layers = [ + {"id": "draw", "name": "그림", "isVisible": True, "isLocked": False}, + {"id": "hidden", "name": "숨김", "isVisible": False, "isLocked": False}, + {"id": "frozen", "name": "동결", "isVisible": True, "isLocked": False, "isFrozen": True}, + ] + return {"format": "openwebcad", "entities": entities, "layers": layers} + + +def _structure_doc(number: str, more: list[str] = ()) -> dict: + sheet = { + "format": "openwebcad", + "entities": [_text("구조물그림", 300, 300, "기본")], + "layers": [], + } + return { + "도번": number, + "도면": sheet, + "더한장": [{"장id": f"s{i}", "이름": name, "도면": sheet} for i, name in enumerate(more)], + } + + +@pytest.fixture +def sample(tmp_path, monkeypatch): + """B07 둘(종단 · 표지) + 구조물 사본 셋(SD-2 둘 = 한 번 · 더한장 하나 · SD-1 하나).""" + from M02_MasterTemplete import M02_Item_Snapshot as snapshot + + base = tmp_path / "structures" + copies = { + "pipe@10__a": ("A", _structure_doc("SD-000002", ["상세"])), + "pipe@20__b": ("B", _structure_doc("SD-000002", ["상세"])), + "pipe@30__c": ("C", _structure_doc("SD-000001")), + } + linked = {} + for owner, (folder, doc) in copies.items(): + (base / folder).mkdir(parents=True) + (base / folder / "도면.json").write_text(json.dumps(doc, ensure_ascii=False), "utf-8") + linked[owner] = {"항목": {"name": f"구조물{folder}"}, "폴더": folder, "부분": ["도면"]} + monkeypatch.setattr(snapshot, "linked_snapshots", lambda root: linked) + monkeypatch.setattr(snapshot, "structures_dir", lambda root: base) + + async def root(_pid): + return tmp_path + + async def fields(_pid): + return dict(TITLE) + + async def b07_list(_pid): + return 7, [ + { + "id": "longitudinal_1", + "kind": "longitudinal", + "label": "종단도(1)", + "confirmed": True, + }, + {"id": "cover", "kind": "cover", "label": "표지", "confirmed": False}, + ] + + async def b07_drawing(_pid, drawing_id): + return {"drawing": _b07_drawing(drawing_id), "confirmed": True} + + monkeypatch.setattr(source, "project_root", root) + monkeypatch.setattr(source, "title_fields", fields) + monkeypatch.setattr(source, "b07_list", b07_list) + monkeypatch.setattr(source, "b07_drawing", b07_drawing) + return tmp_path + + +def _client(session: dict | None = None, project_company: int | None = None) -> TestClient: + app = FastAPI() + app.include_router(router_module.router) + if session is not None: + app.dependency_overrides[auth.verify_session] = lambda: session + if project_company is None: + app.dependency_overrides[auth.require_project_access] = lambda: session + return TestClient(app) + + +SESSION = {"user_id": 1, "company_id": 1, "role": "USER", "is_master": False} + + +def test_paint_color_theme_짝(): + """openwebcad theme.ts paintColor(밝은 바탕) 손셈. + + 자동색 검정 · 밝은 노랑 휘도 0.3 으로 · 어두운 색 그대로. + """ + assert paint_color("#ffffff") == paint_color("#FFF") == paint_color("#f5f7fa") == (0, 0, 0) + assert paint_color("#f6d55c") == (89 / 255, 77 / 255, 33 / 255) + assert paint_color("#336699") == (0x33 / 255, 0x66 / 255, 0x99 / 255) + + +def test_목록_차례_B07_번호_다음_도번_한_번(sample): + pid = uuid4() + body = _client(SESSION).get(f"/api/projects/{pid}/print/drawings").json() + assert [item["id"] for item in body] == [ + "b07:longitudinal_1", + "b07:cover", + "structure:SD-000001", + "structure:SD-000002", + "structure:SD-000002-2", + ] + assert [item["number"] for item in body][:2] == ["1", "2"] + assert body[3]["label"] == "구조물A" and body[4]["label"] == "구조물A · 상세" + assert {item["paper"] for item in body} == {"A1"} + assert {item["source"] for item in body} == {"b07", "structure"} + + +def test_PDF_쪽수_크기_한글_숨긴층(sample): + pid = uuid4() + response = _client(SESSION).post(f"/api/projects/{pid}/print/drawings-pdf", json={}) + assert response.status_code == 200, response.text + assert response.headers["content-type"] == "application/pdf" + assert response.headers["x-aislo-pages"] == "5" + assert json.loads(response.headers["x-aislo-skipped-images"]) == {} + doc = pymupdf.open(stream=response.content, filetype="pdf") + assert doc.page_count == 5 + for page in doc: + assert abs(page.rect.width / PT_PER_MM - 840) < 0.5 + assert abs(page.rect.height / PT_PER_MM - 594) < 0.5 + first = doc[0].get_text() + assert "가나다임도개설공사" in first and "라마바설계" in first # 표제란 한글 그대로 + assert "보이는글" in first + assert "숨은글" not in first and "얼린글" not in first + # 구조물 장 = 얹은 도각 + 장 도번 · 표제란 = 프로젝트 값 + assert "구조물그림" in doc[2].get_text() and "SD-000001" in doc[2].get_text() + assert "SD-000002-2" in doc[4].get_text() and "가나다임도개설공사" in doc[4].get_text() + + +def test_PDF_고른_차례_모르는_id(sample): + pid = uuid4() + client = _client(SESSION) + picked = ["structure:SD-000001", "b07:cover"] + response = client.post(f"/api/projects/{pid}/print/drawings-pdf", json={"ids": picked}) + doc = pymupdf.open(stream=response.content, filetype="pdf") + assert doc.page_count == 2 and "구조물그림" in doc[0].get_text() + bad = client.post(f"/api/projects/{pid}/print/drawings-pdf", json={"ids": ["b07:없음"]}) + assert bad.status_code == 400 + + +class _FakePool: + """require_project_access 가 읽는 프로젝트 회사 한 줄.""" + + def __init__(self, company_id: int): + self.row = (company_id,) + + def acquire(self): + pool = self + + class _Cursor: + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + return False + + async def execute(self, *_): + return None + + async def fetchone(self): + return pool.row + + class _Connection: + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + return False + + def cursor(self): + return _Cursor() + + return _Connection() + + +def test_로그인_회사_권한(sample, monkeypatch): + pid = uuid4() + path = f"/api/projects/{pid}/print/drawings" + assert _client().get(path).status_code == 401 # 쿠키 없음 + no_company = {**SESSION, "company_id": None} + assert _client(no_company, project_company=0).get(path).status_code == 403 + monkeypatch.setattr(auth, "get_db_pool", lambda: _FakePool(2)) + other = _client(SESSION, project_company=2) + assert other.get(path).status_code == 403 # 남의 회사 프로젝트 + assert other.post(f"/api/projects/{pid}/print/drawings-pdf", json={}).status_code == 403 + monkeypatch.setattr(auth, "get_db_pool", lambda: _FakePool(1)) + assert _client(SESSION, project_company=1).get(path).status_code == 200 + + +def test_그림_넣기_못_넣은_수(): + png = pymupdf.Pixmap(pymupdf.csRGB, pymupdf.IRect(0, 0, 4, 4), 0).tobytes("png") + import base64 + + def image(data: str) -> dict: + points = [{"x": 10, "y": 10}, {"x": 50, "y": 10}, {"x": 50, "y": 50}, {"x": 10, "y": 50}] + return {"type": "Image", "layerId": "a", "shapeData": {"points": points, "imageData": data}} + + drawing = { + "entities": [ + image("data:image/png;base64," + base64.b64encode(png).decode()), + image("/api/assets/없는그림.png"), + ], + "layers": [], + } + doc = pymupdf.open() + paper, skipped = add_drawing_page(doc, drawing) + assert skipped == 1 and len(doc[0].get_images()) == 1 + + +def test_미확정_횡단_구조물_Node_번들이_돎(): + """서버 Node(화면 TS 그대로) — 번들 빌드 · 실행 · 배치 없는 장은 그대로.""" + entity = _text("서버그림", 0, 0, "draw") + drawings = {"b07:cross_x": {"entities": [entity], "cross_placements": []}} + assert source.place_structures({"cross_sections": []}, drawings) is True + assert drawings["b07:cross_x"]["entities"] == [entity] + assert Path(source.BUNDLE).is_file() diff --git a/resources/tester/test_302_3_print_quantity.py b/resources/tester/test_302_3_print_quantity.py new file mode 100644 index 000000000..4ee176b66 --- /dev/null +++ b/resources/tester/test_302_3_print_quantity.py @@ -0,0 +1,189 @@ +"""302-3 인쇄 미리보기 「수량산출」 — B08 탭 표를 화면 없이 PrintSheet 로 모음. + +견본 = 검증_sub8 서버 GET 응답(구조물 집계표만 손 견본 · `fixtures/b08_302_3_print_quantity.json`). +`helper_302_3_print_quantity.mjs` 가 화면 코드를 node 로 묶어 같은 응답 · 같은 캐시로 +모으기(`collectQuantitySheets`) 와 B08 화면이 부르는 표 짜기를 나란히 냄 → 쪽 차례 · 이름 · 열 머리 · 줄 대조. +""" + +from __future__ import annotations + +import base64 +import copy +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).with_name("helper_302_3_print_quantity.mjs") +FIXTURE = json.loads( + (Path(__file__).with_name("fixtures") / "b08_302_3_print_quantity.json").read_text("utf-8") +) +PNG = b"\x89PNG\r\n\x1a\n302-3" +B08 = ROOT / "B08_Quantity" + + +def _run(responses: dict, drafts: list | None = None, screen: bool = True) -> dict: + if shutil.which("node") is None: + pytest.skip("node 가 없음") + job = { + "project": "p302", + "responses": responses, + "image_b64": base64.b64encode(PNG).decode(), + "drafts": drafts or [], + "screen": screen, + } + out = subprocess.run( + ["node", str(HELPER), str(ROOT)], + input=json.dumps(job, ensure_ascii=False), + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=300, + check=True, + ) + return json.loads(out.stdout.strip().splitlines()[-1]) + + +def _responses() -> dict: + data = copy.deepcopy(FIXTURE) + routes = data["/quantity/milestone"]["data"]["routes"] + # 경로 그림 둘 — 사용자 그림 · 서버 캡처(auto) 꼴 + data["/quantity/milestone"]["images"] = { + routes[0]["id"]: "a.png", + routes[2]["id"]: "auto/b.png", + } + return data + + +@pytest.fixture(scope="module") +def base() -> dict: + return _run(_responses()) + + +def _same(sheet: dict, screen: dict) -> None: + for key in ("heads", "rows", "total", "mergeSame", "frozen"): + assert sheet[key] == screen[key], (sheet["id"], key) + + +def test_쪽_차례_이름(base): + stations = FIXTURE["/quantity/hydro"]["stations"] + names = [s["name"] for s in base["sheets"]] + head = ["구조물 집계표", "총괄자재수량집계표", "콘크리트소요자재집계표", "자재수량집계표"] + assert names[:4] == head + assert names[4:8] == base["screen"]["earthworkNames"] == [ + "토공집계표", "토적표", "평균운반거리", "종방향 무대운반거리" + ] # fmt: skip + hydro = names[8:-1] + assert len(hydro) == len(stations) == 11 # 배수지점 측점마다 한 장 + assert all(n.startswith("수리집수면적표 ") for n in hydro) + kinds = [n for n, st in zip(hydro, stations) if st["kind"]] + assert kinds and all(n.endswith(" 세월교") for n in kinds) + assert names[-1] == "거리이정표" + assert len({s["id"] for s in base["sheets"]}) == len(names) + + +def test_표_짜기_B08_화면과_같음(base): + sheets = {s["id"]: s for s in base["sheets"]} + screen = base["screen"] + _same(sheets["summary"], screen["summary"]) + for sid in ("material:material", "material:concrete", "material:quantity", + "earthwork:summary", "earthwork:sheet"): # fmt: skip + _same(sheets[sid], screen[sid]) + # 운반 = 화면 표 + 아래 평균운반거리 줄(번호 칸 글) + for sid in ("earthwork:haul", "earthwork:free"): + want = screen[sid] + assert sheets[sid]["heads"] == want["heads"] and sheets[sid]["total"] == want["total"] + assert sheets[sid]["rows"] == want["rows"] + [{"no": a} for a in want["averages"]] + assert "평균운반거리" in want["averages"][0] + _same(sheets["milestone"], {**screen["milestone"]}) + + +def test_견본_수치(base): + sheets = {s["id"]: s for s in base["sheets"]} + summary = sheets["summary"] + assert summary["heads"] == ["측점", "관 공›Φ800m/m›관매설›m", "관 공›Φ800m/m›커플링›ea"] + assert len(summary["rows"]) == 2 and summary["total"]["pipe:len|800"] == 20.5 + sheet = sheets["earthwork:sheet"] + assert ( + len(sheet["heads"]) == 35 and len(sheet["rows"]) == 8 + ) # 견본 32칸 + 측점 · 거리 · 보정량계 + assert ( + sheet["orientation"] == "landscape" + and sheets["earthwork:summary"]["orientation"] == "portrait" + ) + assert sheets["material:concrete"]["rows"] and len(sheets["material:concrete"]["rows"]) == 5 + hydro = next(s for s in base["sheets"] if s["id"].startswith("hydro:")) + assert hydro["heads"] == ["구분", "항목", "값"] + value = {r["item"]: r["value"] for r in hydro["rows"]} + calc = FIXTURE["/quantity/hydro/calc"] + assert value["Q : 홍수량 (㎥/s)"] == f"{calc['flow']:.2f}" + assert value["Qd : 설계 홍수량 (㎥/s) = Q × 2.0"] == f"{calc['design_flow']:.2f}" + assert value["R24 : 확률강우량 (mm/day)"] == f"{calc['r24_mm']:.1f}" + # 서버 계산 = 화면과 같은 입력(기본 < 장 기본 < 연결 < 저장 < 초안) + assert base["posts"] == base["screen"]["hydroInputs"] + + +def test_거리이정표_지도_그림(base): + milestone = base["sheets"][-1] + routes = FIXTURE["/quantity/milestone"]["data"]["routes"] + assert len(milestone["images"]) == 2 + for image, route in zip(milestone["images"], (routes[0], routes[2])): + head, body = image["src"].split(",", 1) + assert head == "data:image/png;base64" and base64.b64decode(body) == PNG + assert image["caption"] == (route["name"].strip() or route["rows"][0]["item"]) + + +def test_캐시_값이면_화면에_보이는_그_값(): + responses = _responses() + stations = responses["/quantity/hydro"]["stations"] + key = f"{stations[0]['chainage_m']:.3f}" + milestone = copy.deepcopy(responses["/quantity/milestone"]["data"]) + milestone["routes"][0]["name"] = "가나상회" + got = _run(responses, [["b08-hydro", {key: {"area_km2": 0.5}}], ["b08-milestone", milestone]]) + assert got["posts"][0]["area_km2"] == 0.5 == got["screen"]["hydroInputs"][0]["area_km2"] + hydro = next(s for s in got["sheets"] if s["id"] == f"hydro:{key}") + assert any(r["value"].startswith("0.5 (") for r in hydro["rows"]) + sheet = got["sheets"][-1] + assert any(r.get("company") == "가나상회" for r in sheet["rows"]) + _same(sheet, got["screen"]["milestone"]) + + +def test_영역_하나_실패_까닭_한_장(): + responses = _responses() + del responses["/quantity/earthwork"] + got = _run(responses, screen=False) + earth = [s for s in got["sheets"] if s["id"].startswith("earthwork")] + assert len(earth) == 1 and earth[0]["name"] == "토공" + assert earth[0]["rows"][0]["note"].startswith("불러오지 못했습니다") + assert got["sheets"][0]["name"] == "구조물 집계표" and got["sheets"][-1]["name"] == "거리이정표" + + +def test_B08_화면이_같은_함수를_씀(): + read = lambda name: (B08 / f"B08_Quantity_{name}.ts").read_text("utf-8") # noqa: E731 + assert "summaryTable(view)" in read("UI_Summary") and "loadSummary(projectId)" in read( + "UI_Summary" + ) + material = read("UI_Material") + assert "materialOptions(built)" in material and "loadMaterials(projectId)" in material + assert "earthworkSheetOptions(table, interval)" in read("UI_Earthwork") + sheets = read("UI_Earthwork_Sheets") + assert "earthworkSummaryOptions(table)" in sheets and "haulOptions(groups, caption)" in sheets + assert "hydroInputs(sheet, station, drafts)" in read("UI_Hydro") + assert "milestoneTable(data, siteInfo()" in read("UI_Milestone") + # 표 짜기 파일은 화면 없음 + for name in ( + "Summary_Table", + "Material_Tables", + "Earthwork_Tables", + "Hydro_Table", + "Milestone_Table", + ): + text = read(name) + assert "document." not in text and "createDataTable" not in text, name + assert len(text.splitlines()) <= 700 + printer = (ROOT / "B09_Estimation/draft/B09_Draft_Print_Quantity.ts").read_text("utf-8") + assert 'from "./B09_Draft_Print_Types"' in printer and "createDataTable" not in printer diff --git a/resources/tester/test_302_4_print_cost.py b/resources/tester/test_302_4_print_cost.py new file mode 100644 index 000000000..658c7c010 --- /dev/null +++ b/resources/tester/test_302_4_print_cost.py @@ -0,0 +1,178 @@ +"""PLAN 302-4 인쇄 미리보기 원가계산 — B09 18장 = 기번3 내역서 시트 차례 · 이름 · 방향 · +쪽 표 = 화면 표 짜기와 같은 줄 · 합계(링크 · 강조 · 고칠 칸 · 단가연결 칸만 뺌). + +차례 = 실무 xlsx(openpyxl · 목차 · 안내 · 숨김 시트 빼고) · 견본 = 임시 폴더 프로젝트(145-2 한 바퀴 꼴) +→ 서버 셈 payload → `print_cost_entry.ts` 를 vite `--ssr` 로 번들해 node 로 대조. +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +import sys +import tempfile +import warnings +from pathlib import Path + +import openpyxl +import pytest + +ROOT = Path(__file__).resolve().parents[2] +for path in (ROOT, ROOT / "resources" / "tester"): + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + +import test_145_2_b09_labor_calc as t145 # noqa: E402 + +from B09_Estimation import B09_Estimation_Prices as prices # noqa: E402 +from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs # noqa: E402 +from B09_Estimation.B09_Estimation_Linked import linked_resource_keys # noqa: E402 +from B09_Estimation.B09_Estimation_Rates import rate_candidates, rate_table # noqa: E402 +from B09_Estimation.B09_Estimation_Router import compute_twice # noqa: E402 + +COST = ROOT / "B09_Estimation" / "draft" / "B09_Draft_Print_Cost.ts" +TABS = ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs.ts" +ENTRY = ROOT / "resources" / "tester" / "print_cost" / "print_cost_entry.ts" +VITE = ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js" +XLSX = ( + ROOT + / "resources/knowledge/original/실무문서/2024년 간선임도(기번3-울진.대흥)(공통)" + / "1. 설계원본/01 내역자료/2024년 간선임도사업(기번3).xlsx" +) +#: 내역서 앞 시트(차례 밖) +FRONT = ("〓 목 차 〓", "※※안내※※") + +ENTRY_RE = re.compile(r'\["(\w+)", "([^"]+)", "(landscape|portrait)"\]') + + +def _cost_sheets() -> list[tuple[str, str, str]]: + src = COST.read_text("utf-8") + body = src[src.index("COST_SHEETS") : src.index("];", src.index("COST_SHEETS"))] + return ENTRY_RE.findall(body) + + +def _practical() -> list[tuple[str, str]]: + with warnings.catch_warnings(): + warnings.simplefilter("ignore") # 인쇄 영역 정의 이름 경고 + wb = openpyxl.load_workbook(XLSX) + return [ + (ws.title, ws.page_setup.orientation or "landscape") + for ws in wb.worksheets + if ws.sheet_state == "visible" and ws.title not in FRONT + ] + + +def test_차례_이름_방향_기번3_내역서(): + ours = _cost_sheets() + assert len(ours) == 18 + assert [(name, o) for _id, name, o in ours] == _practical() + assert all(len(name) <= 31 for _id, name, _o in ours) # 엑셀 시트 이름 한도 + # 탭 이름 = 화면 탭 이름(B09 TABS 앞 18장 · 같은 차례) + tabs = re.findall(r'^ \["(\w+)", "([^"]+)"\],$', TABS.read_text("utf-8"), re.MULTILINE) + assert [(i, n) for i, n, _o in ours] == tabs[:18] + assert not {"schedule_sum", "schedule_calc"} & {i for i, _n, _o in ours} # 공기 초안 뺌 + + +def _payload(root: Path) -> dict: + """145-2 한 바퀴 견본 — 관 둘(대가 · 산근 · 중기 · 사전조합) · 설계내역 한 줄 → 라우터 꼴 payload.""" + t145._snap(root, "pipe@100.0", "SS000901", t145.LINES, t145._values(t145.STONE)) + promoted = [ + {"로직": "GF000119", "승격": True, "수량": {"손": 1}}, + {"참조": {"층": "system", "주인": None, "키": "SC-T"}, "수량": {"손": 4}}, + ] + t145._snap(root, "pipe@400.0", "SS000902", promoted, t145._values(t145.STONE), t145.SC) + inputs = prices.build_inputs(None, linked_resource_keys(root)) + inputs["daega"] = daega_inputs(root) + inputs["hopyo"] = {} + inputs["bill"] = { + "items": [ + {"key": "st:revet", "section": "2.1", "name": "돌기슭막이", "spec": "", "unit": "m", + "qty": 10, "ref": {"tab": "dg_list", "key": "revet"}, "note": "", + "owners": {"pipe@100.0": 4, "pipe@400.0": 6}}, + ] + } # fmt: skip + sheets, summary = compute_twice(root, inputs, {}) + return { + "inputs": inputs, + "user": {}, + "book": None, + "stale": None, + "sheets": sheets, + "rate_candidates": rate_candidates(), + "rate_table": rate_table(), + "notes": [], + "summary": summary, + } + + +@pytest.fixture(scope="module") +def pages(tmp_path_factory) -> list[dict]: + if shutil.which("node") is None: + pytest.skip("node 가 없음") + work = tmp_path_factory.mktemp("p302_4") + root = t145._project(work / "proj", [(100, "SS000901"), (400, "SS000902")]) + data = work / "payload.json" + data.write_text(json.dumps(_payload(root), ensure_ascii=False, default=str), "utf-8") + with tempfile.TemporaryDirectory(prefix="print_cost_test_") 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=180, + ) + assert build.returncode == 0, f"vite ssr 번들 실패:\n{build.stdout}\n{build.stderr}" + run = subprocess.run( + ["node", str(Path(outdir) / "print_cost_entry.js"), str(data)], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=60, + ) + assert run.stdout.strip(), f"시험 진입이 아무 결과도 안 냄:\n{run.stderr}" + return json.loads(run.stdout) + + +def test_쪽_차례_이름_방향(pages): + assert [(p["id"], p["name"], p["orientation"]) for p in pages] == _cost_sheets() + assert all(p["caption"] == p["name"] for p in pages) + + +def test_쪽_표_화면과_같은_줄_합계(pages): + for p in pages: + assert p["sameRows"] and p["rows"] == p["screenRows"], p["id"] + assert p["total"] == p["screenTotal"], p["id"] + assert p["columns"] == [k for k in p["screenColumns"] if k != "connect"], p["id"] + assert p["interactive"] == [], p["id"] # 누름 · 고침 · 강조 뺌 + by = {p["id"]: p for p in pages} + # 견본이 표를 채움 — 내역 · 대가 · 산근 · 중기 · 집계 + for tab in ("bill", "dg_list", "dg", "sg_list", "sg", "machine", "sum_labor", "cost", "total"): + assert by[tab]["rows"] > 0, tab + + +def test_머리_여러_줄_레벨_병합_꼴(pages): + by = {p["id"]: p for p in pages} + bill = by["bill"] + assert bill["groups"] > 0 # 머리 두 줄(재료비 · 노무비 · 경비 묶음) + assert "connect" not in bill["columns"] # 단가연결 메뉴 칸 뺌 + assert bill["level"] == {"key": "name", "tree": False} # 들여쓰기만 · 나무 아이콘 뺌 + assert max(v for v in bill["levels"] if v) >= 2 + assert {c for c in bill["rowClass"] if c} <= {"is-title"} # 손 줄 · 고른 줄 표시 뺌 + assert "is-title" in bill["rowClass"] + assert by["total"]["mergeSame"] == ["part"] # 총괄 공종 칸 세로 병합 diff --git a/resources/tester/test_303_b09_row_popup.py b/resources/tester/test_303_b09_row_popup.py new file mode 100644 index 000000000..f1aa069ed --- /dev/null +++ b/resources/tester/test_303_b09_row_popup.py @@ -0,0 +1,329 @@ +"""303 B09 행 고름 = 팝업 · 하이퍼링크 스위치 · 좌측 하단 줄. + +303-1 좌측 하단 고정 줄 — [박스보기|호표보기] · [하이퍼링크|팝업] · `DRAFT.actions`(비면 줄 없음) · + [?][저장][내역 초기화](도움말 줄) · 스위치 = pref global · 기본 = 팝업 +303-2 표 짜기 = `…_UI_Builders`(화면 · 팝업 · 인쇄 같은 옵션) +""" + +import json +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from resources.tester.helper_b09_group_cards import COMMON, GROUPS, TABS, run # noqa: E402 + +B09 = ROOT / "B09_Estimation" +DOCK = "B09_Estimation/B09_Estimation_UI_Dock.ts" +BUILDERS = "B09_Estimation/B09_Estimation_UI_Builders.ts" + +DOCK_JS = ( + "\nconst save = document.createElement('button'); save.textContent = '저장';" + "\nconst reset = document.createElement('button'); reset.textContent = '내역 초기화';" + "\nconst shape = (dock) => dock.children.map((r) => ({ sw: r.dataset.b09Switch ?? null," + " draft: 'b09DraftActions' in r.dataset, help: 'helpRow' in r.dataset," + " text: r.children.map((b) => b.textContent) }));" +) + + +def test_하단_줄_차례_초안_단추_없으면_줄_없음(): + out = run( + [COMMON, TABS, GROUPS, DOCK], + DOCK_JS + "\nconst a = createDock([save, reset], () => {});" + "\nDRAFT.actions.push(() => { const b = document.createElement('button');" + " b.textContent = '인쇄 미리보기'; return b; });" + "\nconst b = createDock([save, reset], () => {});" + "\nconsole.log(JSON.stringify({ cls: a.className, a: shape(a), b: shape(b) }));", + ) + assert out["cls"] == "ui-sidebar-actions b09-dock" # 바닥 고정 = 공용 액션 줄 한 벌 + view = {"sw": "view", "draft": False, "help": False, "text": ["박스보기", "호표보기"]} + link = {"sw": "link", "draft": False, "help": False, "text": ["하이퍼링크", "팝업"]} + main = {"sw": None, "draft": False, "help": True, "text": ["저장", "내역 초기화"]} + assert out["a"] == [view, link, main] # 초안 단추 없음 = 줄 없음 + draft = {"sw": None, "draft": True, "help": False, "text": ["인쇄 미리보기"]} + assert out["b"] == [view, link, draft, main] # 초안 단추 = [저장] 줄 위 + + +def test_스위치_기본_팝업_누름_pref(): + out = run( + [COMMON, TABS, GROUPS, DOCK], + DOCK_JS + "\nconst dock = createDock([save, reset], () => {});" + "\nconst row = dock.children[1];" + "\nconst pressed = () => row.children.map((b) => b.attrs['aria-pressed']);" + "\nconst a = [linkMode(), pressed()];" + "\nrow.children[0].click();" + "\nconst b = [linkMode(), pressed(), kept.get('b09-link-mode')];" + "\nrow.children[1].click();" + "\nconst c = [linkMode(), pressed(), kept.get('b09-link-mode')];" + "\nkept.set('b09-link-mode', '\"x\"');" + "\nconsole.log(JSON.stringify({ a, b, c, odd: linkMode() }));", + ) + assert out["a"] == ["popup", ["false", "true"]] # 기본 = 팝업 + assert out["b"] == ["link", ["true", "false"], '"link"'] + assert out["c"] == ["popup", ["false", "true"], '"popup"'] + assert out["odd"] == "popup" # 모르는 값 = 팝업 + + +def test_DRAFT_actions_자리(): + tabs = (B09 / "B09_Estimation_UI_Tabs.ts").read_text("utf-8") + assert "actions: Array<() => HTMLElement>;" in tabs + assert "{ builders: {}, extend: [], panels: [], actions: [] }" in tabs + state = (ROOT / "A00_Common/b_page_state.ts").read_text("utf-8") + assert '"b09-link-mode": { bucket: "pref", scope: "global" },' in state + css = (B09 / "B09_Estimation_UI_Page.css").read_text("utf-8") + m = re.search(r"\.b09-dock\.ui-sidebar-actions\s*\{([^}]*)\}", css) + assert m and "flex-direction: column" in m.group(1) # 줄 여럿 세로 + + +# 표 짜기 — 탭 짜기 18 개 대신 가짜(줄 하나) · 옵션 = 표 그대로 넘김 +FAKE_BUILDS = "".join( + f"\nconst {name} = () => ({{ columns: [], rows: [{{ key: '{name}' }}] }});" + for name in re.findall( + r":\s*(build\w+),", + (ROOT / BUILDERS).read_text("utf-8"), + ) +) + + +def test_Builders_화면_같은_옵션(): + out = run( + [TABS, BUILDERS], + "\nDRAFT.builders.draft_x = () => '안내';" + "\nconst spec = { columns: [{ key: 'a', label: 'A' }], rows: [{ key: 'r' }], frozen: 2," + " editable: () => true, onEdit: () => {}, rowClass: () => 'x', cellClass: () => 'y'," + " level: { key: 'a', of: () => 1 }, mergeSame: ['a'], link: () => null, menu: () => [] };" + "\nconst o = tableOptionsOf(spec, 'bill');" + "\nconst plain = tableOptionsOf({ columns: [], rows: [], editOnDouble: false });" + "\nconsole.log(JSON.stringify({ keys: Object.keys(o).sort(), caption: o.caption," + " same: ['columns', 'rows', 'editable', 'onEdit', 'rowClass', 'cellClass', 'level', 'mergeSame']" + ".every((k) => o[k] === spec[k]), frozen: o.frozen, total: o.total, empty: o.empty," + " dbl: [o.editOnDouble, plain.editOnDouble], key: [o.keyEdit, plain.keyEdit]," + " plainCaption: plain.caption ?? null, plainFrozen: plain.frozen," + " bill: tabTableOf('bill', {}).rows[0].key, draft: tabTableOf('draft_x', {})," + " none: tabTableOf('nope', {}) }));", + FAKE_BUILDS, + ) + assert out["keys"] == sorted( + [ + "columns", + "rows", + "total", + "frozen", + "empty", + "caption", + "editable", + "editOnDouble", + "keyEdit", + "onEdit", + "rowClass", + "cellClass", + "level", + "mergeSame", + ] + ) # 누름 · 메뉴 · 풍선 없음(그리는 쪽 몫) + assert out["caption"] == "설계내역서" and out["same"] is True + assert out["frozen"] == 2 and out["total"] is None and out["empty"] == "" + assert out["dbl"] == [True, False] # 243-1 기본 = 더블클릭 + assert out["key"] == [False, True] # 설계내역서(메뉴) Enter · F2 = 레벨 키 몫 + assert out["plainCaption"] is None and out["plainFrozen"] == 0 + assert out["bill"] == "buildBill" and out["draft"] == "안내" and out["none"] is None + + +def test_화면이_Builders_옵션을_씀(): + shell = (B09 / "B09_Estimation_UI_Shell.ts").read_text("utf-8") + assert "...tableOptionsOf(spec, tab)," in shell + assert "const spec = tabTableOf(tab, ctx);" in shell + assert "BUILDERS" not in shell + + +# ── 303-2 팝업 창 ────────────────────────────────────────────────────────── +POPUP = "B09_Estimation/B09_Estimation_UI_Popup.ts" + + +def _fn(path: str, name: str) -> str: + """파일에서 함수 하나 꺼냄(맨 앞 `export` 뗌) — 이어 붙이면 이름이 부딪히는 파일 대신.""" + src = (ROOT / path).read_text("utf-8") + m = re.search(rf"export function {name}\(.*?\n}}\n", src, re.S) + assert m, name + return m.group(0).replace("export ", "", 1) + + +POPUP_PRELUDE = ( + FAKE_BUILDS + + "\nconst GAP = 6;\n" + + _fn("ui_template/ui_template_hover_info.ts", "placeHoverInfo") + + _fn("B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts", "subtreeEnd") + + r""" +E.prototype.remove = function () { + if (this.parent) this.parent.children = this.parent.children.filter((c) => c !== this); + this.parent = null; +}; +E.prototype.getBoundingClientRect = function () { + return this.rect ?? { left: 0, top: 0, right: 0, bottom: 0, width: 400, height: 300 }; +}; +document.body = new E("body"); +const winKeys = []; +const hashFns = []; +globalThis.window = { innerWidth: 1200, innerHeight: 800, + addEventListener: (t, f) => (t === "keydown" ? winKeys : hashFns).push(f) }; +const esc = () => { + const ev = { key: "Escape", stopped: false, preventDefault() {}, stopPropagation() { ev.stopped = true; } }; + winKeys.forEach((f) => f(ev)); + return ev.stopped; +}; +const tables = []; +const createDataTable = (o) => { + const root = document.createElement("div"); + o.rows.forEach((r, i) => { + const tr = document.createElement("tr"); + tr.dataset.i = String(i); + tr.rect = { left: 40, top: 300 + i * 28, right: 900, bottom: 328 + i * 28 }; + root.append(tr); + }); + tables.push(o); + return { root }; +}; +""" +) + +SCENE = r""" +const col = [{ key: "name", label: "명칭", group: ["머리"] }]; +const specs = { + bill: { columns: col, menu: () => [], level: { key: "name", of: (i) => [1, 2, 2, 1][i] }, + rows: [{ key: "b1", level: 1 }, { key: "b2", level: 2 }, { key: "b3", level: 2 }, { key: "b4", level: 1 }], + rowClass: (i) => (i === 0 ? "is-title" : null), + editable: (i, k) => k === "qty", + link: (i) => ({ tab: "labor", key: "L1" }) }, + labor: { columns: col, rows: [{ key: "L0" }, { key: "L1" }, { key: "L2" }], + link: (i) => (i === 1 ? { tab: "dg", key: "D1|1" } : null) }, + dg: { columns: col, rows: [{ key: "D1" }, { key: "D1|1" }, { key: "D1|합계" }, { key: "D2" }] }, +}; +const gone = []; +const base = { specOf: (t) => specs[t] ?? null, go: (l) => gone.push(l) }; +const anchor = document.createElement("tr"); +anchor.rect = { left: 40, top: 72, right: 900, bottom: 100 }; +const boxes = () => document.body.children.map((b) => b.dataset.b09Popup); +const rows = (k) => tables[k].rows.map((r) => r.key); +const cls = (k) => tables[k].rows.map((_, i) => tables[k].rowClass(i)); +""" + + +def _popup(body: str): + return run([TABS, GROUPS, BUILDERS, POPUP], SCENE + body, POPUP_PRELUDE) + + +def test_강조_범위_줄_수(): + out = _popup( + "\nconsole.log(JSON.stringify([" + "popupRange('bill', specs.bill, 'b1'), popupRange('bill', specs.bill, 'b4')," + "popupRange('labor', specs.labor, 'L1'), popupRange('dg', specs.dg, 'D1|1')," + "popupRange('labor', specs.labor, 'X')]));" + ) + # 설계내역서 = 그 줄 + 자손 · 그 밖 = 한 줄 · 묶음 = 호표 구간 · 없음 = null + assert out == [[0, 2], [3, 3], [1, 1], [0, 2], None] + + +def test_창_열_제목_강조_줄_자리(): + out = _popup( + "\nopenRowPopup({ ...base, link: { tab: 'bill', key: 'b1' }, anchor });" + "\nconst box = document.body.children[0];" + "\nconst o = tables[0];" + "\nconsole.log(JSON.stringify({ boxes: boxes(), n: popupCount(), cls: box.className," + " head: box.children[0].children.map((e) => e.textContent), rows: rows(0), rowCls: cls(0)," + " columns: o.columns === specs.bill.columns, caption: o.caption, total: o.total," + " editable: o.editable ?? null, lv: [0, 1, 2].map((i) => o.level.of(i))," + " table: box.children[1].dataset.b09Table, left: box.style.left, top: box.style.top }));" + ) + assert out["boxes"] == ["bill"] and out["n"] == 1 + assert out["cls"] == "b09-estimation b09-popup" # 창 안 = b09 표 꼴(빨강 테두리) + assert out["head"] == ["설계내역서", "탭으로", "×"] + assert out["rows"] == ["b1", "b2", "b3"] # 그 줄 + 자손 + assert out["rowCls"] == ["is-title is-pick is-pick-top", "is-pick", "is-pick is-pick-end"] + assert out["columns"] is True and out["caption"] == "설계내역서" # 열 제목(머리 여러 줄) 그대로 + assert out["total"] is None and out["editable"] is None # 합계 · 고칠 칸 없음 + assert out["lv"] == [1, 2, 2] and out["table"] == "bill" + assert out["left"] == "40px" and out["top"] == "106px" # 누른 줄 아래(간격 6) + + +def test_이어진_창_겹침_Esc_차례(): + out = _popup( + "\nopenRowPopup({ ...base, link: { tab: 'bill', key: 'b1' }, anchor });" + "\ntables[0].onCellClick(1, 'qty');" # 고칠 칸 = 안 엶 + "\nconst a = boxes();" + "\ntables[0].onCellClick(1, 'name');" # 창 안 링크 줄 = 이어진 창 + "\nconst second = document.body.children[1];" + "\ntables[1].onCellClick(0, 'name');" # 창 안 줄 0 = 원래 줄 1(L1) → dg + "\nconst b = boxes();" + "\nconst dg = rows(2);" + "\nconst e1 = esc(); const c = boxes();" + "\nconst e2 = esc(); const e3 = esc(); const e4 = esc();" + "\nconsole.log(JSON.stringify({ a, b, dg, top2: second.style.top, c, e: [e1, e2, e3, e4]," + " d: boxes(), gone }));" + ) + assert out["a"] == ["bill"] + assert out["b"] == ["bill", "labor", "dg"] # 겹쳐 위에(뒤 DOM = 위) + assert out["dg"] == ["D1", "D1|1", "D1|합계"] # 호표 구간 + assert out["top2"] == "362px" # 그 줄(창 안 2번째 줄) 곁 + assert out["c"] == ["bill", "labor"] # Esc = 맨 위 하나 + assert out["e"] == [True, True, True, False] # 창 없으면 Esc 안 가로챔 + assert out["d"] == [] and out["gone"] == [] + + +def test_닫기_탭으로_새_누름_주소(): + out = _popup( + "\nconst open = (key = 'b1') => openRowPopup({ ...base, link: { tab: 'bill', key }, anchor });" + "\nopen(); tables[0].onCellClick(0, 'name');" + "\nconst head = (k) => document.body.children[k].children[0].children;" + "\nhead(0)[2].click();" # 아래 창 × = 그 창 + 위 창 + "\nconst a = boxes();" + "\nopen(); tables[2].onCellClick(0, 'name'); open('b4');" # 화면 새 누름 = 있던 창 모두 닫고 새로 + "\nconst b = [boxes(), rows(4)];" + "\nhead(0)[1].click();" # [탭으로] = 옮기고 모두 닫음 + "\nconst c = [boxes(), [...gone]];" + "\nopen(); hashFns.forEach((f) => f());" + "\nconst d = boxes();" + "\nopenRowPopup({ ...base, link: { tab: 'labor', key: 'X' }, anchor });" # 보일 줄 없음 = 옮김 + "\nconsole.log(JSON.stringify({ a, b, c, d, e: [boxes(), gone.length] }));" + ) + assert out["a"] == [] + assert out["b"] == [["bill"], ["b4"]] + assert out["c"] == [[], [{"tab": "bill", "key": "b4"}]] + assert out["d"] == [] # 주소 바뀜 = 모두 닫음 + assert out["e"] == [[], 2] + + +def test_창_자리_화면_안(): + out = _popup( + "\nconst v = { width: 1200, height: 800 };" + "\nconsole.log(JSON.stringify([" + "popupPlace({ left: 40, top: 700, right: 900, bottom: 728 }, { width: 400, height: 300 }, v)," + "popupPlace({ left: 1000, top: 100, right: 1190, bottom: 128 }, { width: 400, height: 300 }, v)," + "popupPlace({ left: 40, top: 400, right: 900, bottom: 428 }, { width: 400, height: 700 }, v)]));" + ) + assert out[0] == {"x": 40, "y": 394} # 아래 모자람 = 위 + assert out[1] == {"x": 794, "y": 134} # 오른쪽 화면 안 + assert out[2] == {"x": 40, "y": 92} # 위아래 다 모자람 = 화면 안으로 밀어 넣음 + + +def test_화면_잇기_모드별_누름_길(): + shell = (B09 / "B09_Estimation_UI_Shell.ts").read_text("utf-8") + assert ( + 'if (linkMode() === "link") return show(link.tab, link.key);' in shell + ) # 하이퍼링크 그대로 + assert "openRowPopup({" in shell and "go: (to) => show(to.tab, to.key)," in shell + assert "closePopups(); // 303-2 탭 바꿈" in shell # 탭 바꿈 = 모두 닫음 + assert 'goto(spec.link(i, key), made.root.querySelector(`tr[data-i="${i}"]`));' in shell + assert "if (spec.link && !spec.editable?.(i, key))" in shell # 고칠 칸 = 안 옮김 + assert ( + "createCheckPanel(refresh, (link) => show(link.tab, link.key));" in shell + ) # 연결 없음 = 옮김 + groups = (B09 / "B09_Estimation_UI_Tabs_Groups.ts").read_text("utf-8") + assert 'cards.querySelector(`[data-at="${i}"]`)' in groups # 박스 카드 누름도 같은 길 + popup = (ROOT / POPUP).read_text("utf-8") + assert "...tableOptionsOf(spec, tab)," in popup # 화면과 같은 표 짜기 + css = (B09 / "B09_Estimation_UI_Page.css").read_text("utf-8") + m = re.search(r"\.b09-estimation\.b09-popup\s*\{([^}]*)\}", css) + assert m and "max-width: 90vw" in m.group(1) and "position: fixed" in m.group(1) diff --git a/resources/tester/test_304_b09_view_setting.py b/resources/tester/test_304_b09_view_setting.py new file mode 100644 index 000000000..5f29ce13b --- /dev/null +++ b/resources/tester/test_304_b09_view_setting.py @@ -0,0 +1,103 @@ +"""304-1 B09 박스보기 · 호표보기 = 좌측 하단 한 설정(묶음 탭 셋 함께). + +- 한 값(`b09-group-view` · pref global)이 dg · machine_blocks · sg 셋에 함께 · 기본 = 박스(카드) +- 옛 탭별 값(`{탭: 보기}`) = 첫 읽기에 한 값으로(dg · 없으면 다른 탭 · 없으면 기본) 옮겨 적음 +- 보조 탭 줄 보기 토글 걷음 · 가로 열 수 단추(박스 보기)는 그대로 +- 좌측 하단 스위치 [박스보기|호표보기] 누름 = 값 바꿈 · 다시 그림 부름 +""" + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from resources.tester.helper_b09_group_cards import COMMON, GROUPS, RAW, TABS, run # noqa: E402 + +LABOR = "B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts" +DOCK = "B09_Estimation/B09_Estimation_UI_Dock.ts" +DG = {"id": "DG1", "no": 1, "name": "찰쌓기", "unit": "m2", "lines": [], "sum": 0} +SPEC = ( + "\nconst spec = buildDaegaBlocks({ sheets: { daega_blocks: [" + + json.dumps(DG, ensure_ascii=False) + + "] } });" + "\nconst tools = [];" + "\nconst draw = (tab) => renderGroupPanel({ tab, spec, hint: null, goto: () => {}," + " setTools: (t) => tools.push(...t)," + " makeTable: () => ({ root: document.createElement('table'), focusRow: () => {} }) })[0];" + "\nconst kinds = () => ['dg', 'machine_blocks', 'sg'].map((t) => draw(t).tagName);" +) + + +def test_한_설정_셋_탭_함께(): + out = run( + [COMMON, TABS, LABOR, GROUPS], + SPEC + "\nconst a = kinds();" + "\nchooseGroupView('table');" + "\nconst b = kinds();" + "\nconsole.log(JSON.stringify({ a, b, kept: Object.fromEntries(kept)," + " toggles: tools.filter((e) => e.dataset.b09GroupView).length," + " cols: tools.flatMap((e) => e.all('[data-group-cols]')).length }));", + RAW, + ) + assert out["a"] == ["DIV"] * 3 # 기본 = 박스 + assert out["b"] == ["TABLE"] * 3 # 한 값이 셋 탭 함께 + assert json.loads(out["kept"]["b09-group-view"]) == "table" + assert out["toggles"] == 0 # 탭 줄 보기 토글 없음 + assert out["cols"] == 4 * 3 # 박스 보기 가로 열 수 단추 = 탭 줄 그대로 + + +def test_옛_탭별_값_첫_읽기에_한_값으로(): + cases = [ + ({"dg": "table", "sg": "cards"}, "table"), + ({"dg": "cards", "sg": "table"}, "cards"), # dg 먼저 + ({"sg": "table"}, "table"), # dg 없으면 다른 탭 + ({"machine_blocks": "table", "sg": "cards"}, "table"), + ({}, "cards"), # 없으면 기본 + ] + for old, want in cases: + out = run( + [COMMON, TABS, LABOR, GROUPS], + "\nkept.set('b09-group-view', " + + json.dumps(json.dumps(old)) + + ");" + + SPEC + + "\nconst got = kinds();" + "\nconsole.log(JSON.stringify({ got, kept: Object.fromEntries(kept) }));", + RAW, + ) + assert out["got"] == ["TABLE" if want == "table" else "DIV"] * 3, old + assert json.loads(out["kept"]["b09-group-view"]) == want, old # 옮겨 적음 + + +def test_좌측_스위치_누름(): + out = run( + [COMMON, TABS, GROUPS, DOCK], + "\nlet redraws = 0;" + "\nconst dock = createDock([document.createElement('button')], () => redraws++);" + "\nconst row = dock.children[0];" + "\nconst pressed = () => row.children.map((b) => b.attrs['aria-pressed']);" + "\nconst labels = row.children.map((b) => b.textContent);" + "\nconst a = pressed();" + "\nrow.children[0].click();" # 이미 박스 = 그대로 + "\nconst b = [redraws, pressed()];" + "\nrow.children[1].click();" + "\nconsole.log(JSON.stringify({ name: row.dataset.b09Switch, labels, a, b, c: [redraws, pressed()]," + " view: groupView(), kept: Object.fromEntries(kept) }));", + ) + assert out["name"] == "view" and out["labels"] == ["박스보기", "호표보기"] + assert out["a"] == ["true", "false"] # 기본 = 박스보기 + assert out["b"] == [0, ["true", "false"]] + assert out["c"] == [1, ["false", "true"]] and out["view"] == "table" + assert json.loads(out["kept"]["b09-group-view"]) == "table" # [저장] 없이 pref + + +def test_잇기(): + groups = (ROOT / GROUPS).read_text("utf-8") + assert "toggle" not in groups and "redraw" not in groups and "createButton" not in groups + shell = (ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts").read_text("utf-8") + assert "createDock([save, reset], () => isGroupTab(current) && show(current))" in shell + state = (ROOT / "A00_Common/b_page_state.ts").read_text("utf-8") + assert '"b09-group-view": { bucket: "pref", scope: "global" },' in state diff --git a/resources/tester/test_61_1_b08_summary_table.py b/resources/tester/test_61_1_b08_summary_table.py index c7454065a..002f80fb0 100644 --- a/resources/tester/test_61_1_b08_summary_table.py +++ b/resources/tester/test_61_1_b08_summary_table.py @@ -94,7 +94,9 @@ def test_측점_조합들(): def test_집계표는_공용_표(): # PLAN 70-2 — 공용 많은 자료 표 · 측점 열 고정 · 칸은 빈칸(「—」 아님) - ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity" / "B08_Quantity_Summary_Table.ts" + ).read_text(encoding="utf-8") assert "createDataTable(" in ui and "@ui/ui_template_data_table" in ui assert "frozen: 1," in ui and 'empty: ""' in ui and "onRowClick" in ui assert 'document.createElement("table")' not in ui diff --git a/resources/tester/test_69_b08_earthwork_tabs.py b/resources/tester/test_69_b08_earthwork_tabs.py index 8b533cf56..7463fcaca 100644 --- a/resources/tester/test_69_b08_earthwork_tabs.py +++ b/resources/tester/test_69_b08_earthwork_tabs.py @@ -209,7 +209,9 @@ def test_토공집계표_식() -> None: def test_토적표_머리_글자는_사용자_견본대로(): # 「[절토] <토사> · <연암> · 측구파기 <토사> · <암>」 — 값 · 계산은 그대로 - ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Earthwork.ts").read_text(encoding="utf-8") + ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Earthwork.ts").read_text(encoding="utf-8") + ( + ROOT / "B08_Quantity" / "B08_Quantity_Earthwork_Tables.ts" + ).read_text(encoding="utf-8") for head in ( '["절토", "연암"]', '["절토", "경암"]', diff --git a/resources/tester/test_98_b08_pipe_summary.py b/resources/tester/test_98_b08_pipe_summary.py index 87884abab..ecc2964a4 100644 --- a/resources/tester/test_98_b08_pipe_summary.py +++ b/resources/tester/test_98_b08_pipe_summary.py @@ -249,7 +249,9 @@ def test_라우터_등록(): def test_화면이_서버_칸을_그림(): """98-4 — 화면은 서버 배관 열 · 칸 · 계만 그림(계산 없음) · 배관 조합 열은 서버 열로 바뀜.""" base = Path(__file__).resolve().parents[2] - ui = (base / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ui = (base / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") + ( + base / "B08_Quantity/B08_Quantity_Summary_Table.ts" + ).read_text(encoding="utf-8") assert "/quantity/summary" in ui and "pipeColumns(pipe, comboPath)" in ui # 배관 · 횡단배수 조합 열 대신 서버 열(120 — 서버가 펼친 종류 `types` 를 뺌) assert '!served.has(point.facility ?? "pipe")' in ui