Merge remote-tracking branch 'origin/CODEX' into CODEX

This commit is contained in:
2026-10-13 09:02:58 +09:00
95 changed files with 8328 additions and 1344 deletions
+3 -1
View File
@@ -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 에 안 둠 */
@@ -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<SpecForm, "state" | "setValue">,
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)),
);
}
+3 -1
View File
@@ -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); // 아울렛에 페이지 렌더
};
+29 -4
View File
@@ -74,6 +74,29 @@ const routeTable: Partial<Record<RoutePath, () => Promise<PageRenderer>>> = {
(await import("../M02_MasterTemplete/M02_MasterTemplete_UI_Page")).renderM02MasterTemplate,
};
/** 초안 길(302-1) — 페이지 폴더 `draft/*_Route.ts` 의 `ROUTE` 선언 · 폴더째 지우면 길도 없음.
* 정적 표에 이름을 박지 않음 — 보호 · 단계 화면(로그인 · 회사) · 프로젝트 · 꼬리말 없음도 선언에서. */
export interface DraftRoute {
path: string;
load: () => Promise<PageRenderer>;
/** 로그인 · 회사 필요(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<boolean>) | null = null;
export const setLeaveGuard = (guard: (() => Promise<boolean>) | null): void => {
@@ -159,7 +182,9 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise<void> {
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<void> {
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<void> {
}
// 기억한 현재 프로젝트가 지워졌으면 페이지 대신 안내 — 모든 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<void> {
}
}
const loader = routeTable[route];
const loader = routeTable[route] ?? draft?.load;
if (!loader) {
renderPlaceholder(outlet, route);
return;
@@ -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,
});
@@ -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<string, string>();
readonly childNodes: Child[] = [];
parentNode: FakeElement | null = null;
readonly dataset: Record<string, string> = {};
readonly style: Record<string, unknown> = { 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<T extends Child>(node: T): T {
this.append(node);
return node;
}
prepend(...nodes: Child[]): void {
this.childNodes.unshift(...nodes.map((node) => this.adopt(node)));
}
insertBefore<T extends Child>(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<T extends Child>(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<string, unknown>).document = fakeDocument;
/* ── 범위 재기 ─────────────────────────────────────────────────────────── */
/**
@@ -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<string, unknown>[]; cross_placements?: CrossPlacement[] },
): number {
const placements = drawing.cross_placements ?? [];
let added = 0;
const fresh: Entity[] = [];
const replaced: number[][] = [];
const designIds = new Set<string>();
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;
}
@@ -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<string, string>();
readonly childNodes: Child[] = [];
parentNode: FakeElement | null = null;
readonly dataset: Record<string, string> = {};
readonly style: Record<string, unknown> = { 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<T extends Child>(node: T): T {
this.append(node);
return node;
}
prepend(...nodes: Child[]): void {
this.childNodes.unshift(...nodes.map((node) => this.adopt(node)));
}
insertBefore<T extends Child>(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<T extends Child>(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<string, unknown>).document = fakeDocument;
export {}; // 모듈로 둠 — 이름이 전역에 새지 않게
@@ -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<string>();
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);
}
@@ -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<string, Face>;
}
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<string, number>;
conversion_factors: Record<string, { compacted: number }>;
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<Record<string, unknown>>, table.totals, c);
for (const record of [...table.rows, table.totals])
splitRockColumns(record as unknown as Record<string, unknown>, 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<string, number>;
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 };
}
+188
View File
@@ -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<string, Partial<HydroInputs>>;
export type HydroPayload = { stations: HydroStation[]; saved: Drafts } & HydroOptions;
export const keyOf = (station: HydroStation): string => station.chainage_m.toFixed(3);
const linkedOf = (station: HydroStation): Record<string, unknown> => station.linked ?? {};
/** 저장본 · 초안 한 겹 — 연결값이 있는 칸의 null 은 건너뜀(연결값을 덮지 않게) */
const layer = (station: HydroStation, values: Partial<HydroInputs> = {}) =>
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<HydroPayload>(`/projects/${projectId}/quantity/hydro`),
stationInterval(projectId).catch(() => 20),
]);
return { sheet, interval, drafts: readState<Drafts>("b08-hydro", projectId) ?? {} };
}
export const hydroCalc = (projectId: string, inputs: HydroInputs): Promise<HydroResult> =>
request<HydroResult>(`/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}` : ""}`,
};
}
@@ -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 = <T>(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<T>(path: string, init: RequestInit = {}): Promise<T> {
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<Payload>(`/projects/${projectId}/quantity/materials`);
const draft = readState<MaterialOverrides>("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<string, Cells> | 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<MaterialSheetId, (s: MaterialSheets, o: MaterialOverrides) => 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,
});
@@ -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<Dest, string> = { 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}`,
};
}
+245
View File
@@ -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<number> {
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<string, number> }>;
total: Record<string, number>;
notes: string[];
/** 서버가 펼친 종류(`type_id` · 관 지점은 `facility`) — 조합 열에서 뺌 */
types: string[];
one_length_m: number | null;
pipes: PipeLength[];
/** 연결된 사본 `{구조물 id: 사본}`(143-1) */
linked: Record<string, LinkedSnapshot>;
/** 미연결 시설(154-1) */
unlinked: UnlinkedFacility[];
/** 모든 시설 칸(모달 「조합 연결」 · 156-1) */
facilities: UnlinkedFacility[];
}
async function fetchPipeSummary(projectId: string): Promise<PipeSummary> {
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<string, LinkedSnapshot>;
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<SummaryView> {
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 };
}
+14 -223
View File
@@ -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<string, Face>;
}
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<string, number>;
conversion_factors: Record<string, { compacted: number }>;
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<string, number>;
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<void> {
host.replaceChildren(note("토공 수량을 만드는 중…"));
let table: EarthworkTable;
let interval = 20;
let loaded: Awaited<ReturnType<typeof loadEarthwork>>;
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<Record<string, unknown>>, table.totals, c);
for (const record of [...table.rows, table.totals])
splitRockColumns(record as unknown as Record<string, unknown>, 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";
@@ -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");
}
+18 -68
View File
@@ -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<string, Partial<HydroInputs>>;
type HydroPayload = { stations: HydroStation[]; saved: Drafts } & HydroOptions;
export async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
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<void> {
host.replaceChildren(note("홍수량 계산서를 불러오는 중…"));
let sheet: HydroPayload;
let interval = 20;
let drafts: Drafts = {};
try {
[sheet, interval] = await Promise.all([
request<HydroPayload>(`/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<Drafts>("b08-hydro", projectId) ?? {};
const linkedOf = (station: HydroStation): Record<string, unknown> => station.linked ?? {};
/** 저장본 · 초안 한 겹 — 연결값이 있는 칸의 null 은 건너뜀(연결값을 덮지 않게) */
const layer = (station: HydroStation, values: Partial<HydroInputs> = {}) =>
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<void> => {
const mine = ++seq;
try {
const result = await request<HydroResult>(`/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)}`);
+21 -224
View File
@@ -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<MaterialSheetId, string> = {
material: "할증 %는 칸에서 고침 · 모래 · 자갈 · 시멘트는 좌측 패널",
concrete: "㎥당 배합은 칸에서 고침",
@@ -58,25 +52,6 @@ const HINTS: Record<MaterialSheetId, string> = {
};
const HIDDEN = "값 없는 줄은 숨김";
/** 보일 줄 차례(원래 줄 번호) — 값 없는 줄은 숨김(143-3) */
const shown = <T>(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<T>(path: string, init: RequestInit = {}): Promise<T> {
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<string, Cells> | 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<string, Cells> = {
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<MaterialSheetId, (s: MaterialSheets, o: MaterialOverrides) => Table> = {
material: totalTable,
concrete: concreteTable,
quantity: quantityTable,
};
/** 보조 탭 한 장 — 불러오는 동안 한 줄 · 실패하면 까닭 한 줄. */
export async function renderMaterialSheet(
host: HTMLElement,
@@ -315,21 +124,15 @@ export async function renderMaterialSheet(
sheet: MaterialSheetId,
): Promise<void> {
host.replaceChildren(note("자재 집계표를 불러오는 중…"));
let payload: Payload;
let loaded: Awaited<ReturnType<typeof loadMaterials>>;
try {
payload = await request<Payload>(`/projects/${projectId}/quantity/materials`);
loaded = await loadMaterials(projectId);
} catch (error) {
host.replaceChildren(note(`자재 집계표를 불러오지 못했습니다 — ${String(error)}`));
return;
}
const draft = readState<MaterialOverrides>("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);
+6 -40
View File
@@ -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<string, unknown> | 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<MilestoneRoute> = {}): 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();
@@ -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<Dest, string> = { start: "시점", mid: "중점" };
/** 그 경로를 고쳐 캐시에 */
export function editRoute(
projectId: string,
+19 -197
View File
@@ -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<number> {
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<string, number> }>;
total: Record<string, number>;
notes: string[];
/** 서버가 펼친 종류(`type_id` · 관 지점은 `facility`) — 조합 열에서 뺌 */
types: string[];
one_length_m: number | null;
pipes: PipeLength[];
/** 연결된 사본 `{구조물 id: 사본}`(143-1) */
linked: Record<string, LinkedSnapshot>;
/** 미연결 시설(154-1) */
unlinked: UnlinkedFacility[];
/** 모든 시설 칸(모달 「조합 연결」 · 156-1) */
facilities: UnlinkedFacility[];
}
async function fetchPipeSummary(projectId: string): Promise<PipeSummary> {
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<void> {
export async function renderStructureSummary(host: HTMLElement, projectId: string): Promise<void> {
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,
+28
View File
@@ -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")
@@ -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<Record<TabId, TabBuild>> = {
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,
};
}
+106
View File
@@ -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<LinkMode, string> = { link: "하이퍼링크", popup: "팝업" };
/** 행 누름 길 — 모르는 값 · 못 읽음 = 팝업 */
export function linkMode(): LinkMode {
try {
return readState<unknown>("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<T extends string>(
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<LinkMode>(
"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;
}
+54
View File
@@ -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;
}
+175
View File
@@ -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`;
}
+25 -67
View File
@@ -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<string, string> = {};
const BUILDERS: Partial<Record<TabId, TabBuild>> = {
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<T>(path: string, init: RequestInit = {}): Promise<T> {
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);
+4 -2
View File
@@ -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<string, TabBuild>;
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"] => ({
+40 -38
View File
@@ -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<string, unknown> | 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<string, unknown>) : 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<TabId, GroupView>();
/** 묶음 탭 — 카드 = 카드 판 + 안내 글 · 표 = 표 + 안내 글 · 보기 단추 · 열 수 = `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)));
@@ -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<PrintSheet["orientation"]>;
/** 기번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<Row>("b09-estimation", projectId) ?? payload.user ?? {};
return costSheetsOf(payload, user);
};
@@ -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<Error> {
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<PrintDrawingItem, "name">[];
return items.map((item): PrintDrawingItem => ({ ...item, name: item.label }));
};
const skipped = new WeakMap<Blob, Record<string, number>>();
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<string, number> {
return skipped.get(blob) ?? {};
}
@@ -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<typeof placeStructureEntities>[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<string, Drawing>;
};
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");
@@ -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
@@ -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),
},
)
@@ -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
@@ -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;
}
@@ -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<Blob>;
xlsx?: (ids: string[]) => Promise<Blob>;
/** 그 PDF 곁 알림(도면 = 못 넣은 그림 수) */
note?: (pdf: Blob) => string;
}
async function post(projectId: string, path: string, body: unknown): Promise<Blob> {
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<Area | null> {
const def = AREAS.find((a) => a.id === id)!;
const load = MODS[def.file];
if (!load) return null;
const mod = (await load()) as Record<string, unknown>;
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<string, number>) | 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<string>(); // 체크 뺀 쪽
let seq = 0;
let shown: { url: string; blob: Promise<Blob> } | 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);
@@ -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<PrintSheet[]>): Promise<PrintSheet[]> =>
make().catch((error: unknown) => [
noteSheet(
id,
name,
`불러오지 못했습니다 — ${error instanceof Error ? error.message : String(error)}`,
),
]);
async function summarySheets(projectId: string): Promise<PrintSheet[]> {
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<PrintSheet[]> {
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<PrintSheet[]> {
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<PrintSheet[]> {
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<string> {
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<PrintSheet[]> {
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();
};
@@ -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,
};
@@ -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 = (
'<div style="width:100%;font-size:9px;text-align:center;font-family:sans-serif">'
'<span class="pageNumber"></span> / <span class="totalPages"></span></div>'
)
_lock = threading.Lock() # ponytail: 서버 전체 한 줄 — 브라우저 한 번에 하나
class PrintError(Exception):
"""까닭 한 줄(예외 원문 없음)."""
def _titled(table: str, name: str) -> str:
"""표 머리 맨 위에 시트 이름 줄 — thead 가 쪽마다 되풀이되므로 쪽 머리가 됨."""
span = max(1, table.count("<col "))
row = f'<tr><th class="sheet-title" colspan="{span}">{htmlmod.escape(name)}</th></tr>'
return table.replace("<thead>", f"<thead>{row}", 1) if "<thead>" in table else table
def document_html(title: str, sheets: list[dict[str, Any]]) -> str:
"""시트마다 한 덩이(새 쪽 · 방향) — 표 머리에 시트 이름."""
parts = [
f'<section class="sheet is-{s.get("orientation") or "portrait"}">'
f"{_titled(s['html'], s['name'])}</section>"
for s in sheets
]
return (
f'<!doctype html><html><head><meta charset="utf-8"><title>{htmlmod.escape(title)}</title>'
f"<style>{PRINT_CSS}</style></head><body>{''.join(parts)}</body></html>"
)
#: 시트 덩이마다 쪽 폭에 맞춰 줄임(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="<span></span>",
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")
@@ -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<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" };
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) =>
`<figure class="sheet-figure"><img src="${escHtml(im.src)}" alt="">` +
(im.caption ? `<figcaption>${escHtml(im.caption)}</figcaption>` : "") +
`</figure>`,
)
.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<PrintSheet> {
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<string>((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) };
}
@@ -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<PrintSheet[]>;
/** 도면 영역 한 장 — id = fetchDrawingsPdf 의 ids 로 돌려줌 */
export interface DrawingItem {
id: string;
name: string;
}
export type FetchDrawingList = (projectId: string) => Promise<DrawingItem[]>;
/** ids 없음 = 목록 전부 · 있으면 그 차례대로 */
export type FetchDrawingsPdf = (projectId: string, ids?: string[]) => Promise<Blob>;
@@ -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;
});
+2
View File
@@ -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
File diff suppressed because it is too large Load Diff
+29 -16
View File
@@ -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));
@@ -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 ===
@@ -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 });
}
@@ -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";
@@ -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));
@@ -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) }),
);
@@ -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";
@@ -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 });
}
+3 -1
View File
@@ -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
@@ -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
@@ -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
+6 -2
View File
@@ -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 (
@@ -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
@@ -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 미연결 램프
@@ -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_자재_셋_값_없는_줄_숨김():
+2 -1
View File
@@ -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")
@@ -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
@@ -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_화면_잇기():
@@ -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
) # 칸 이름 = 모달과 같은 규칙
+3 -1
View File
@@ -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")
@@ -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
+14 -15
View File
@@ -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_잇기():
+3 -3
View File
@@ -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
@@ -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")
+1 -1
View File
@@ -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 하단 고정 줄 맨 아래 줄
+8 -8
View File
@@ -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 탭 줄 토글 걷음
@@ -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");
@@ -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: () => {} }) };"
@@ -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"),
]
@@ -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 # 판 + 안내 줄(맨 아래)
@@ -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
+2 -1
View File
@@ -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",
+3 -1
View File
@@ -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
) # 제작 설명 줄 없음
# ── 창 끝까지 셈 ────────────────────────────────────────────────────────────
@@ -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
@@ -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}`))));",
)
+4 -1
View File
@@ -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 · 자동 저장 없음
@@ -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
@@ -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 = {};"
@@ -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
@@ -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]:
@@ -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_길이는_집계_수량에_안_실림():
+442
View File
@@ -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: "되메우기 <A&B>", 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 "되메우기 <A&B>" 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"<tr><td>줄{i}</td><td data-num>{i}</td></tr>" for i in range(120))
html = (
'<table class="ui-data-table__table" style="--dt-w:3000px"><colgroup>'
'<col style="width:1500px"><col style="width:1500px"></colgroup>'
f"<thead><tr><th>이름</th><th>값</th></tr></thead><tbody>{rows}</tbody></table>"
)
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 = '<table><thead></thead></table><img src="http://127.0.0.1:9/x.png">'
doc = pr.document_html("t", [{"name": "s", "orientation": "portrait", "html": html}])
assert "<script" not in pr.PRINT_CSS
try:
assert pr.spawn_pdf(doc)[:5] == b"%PDF-" # 막힌 그림 = 빈 칸 · PDF 는 나옴
except pr.PrintError as error:
pytest.skip(str(error))
def _assert_print(wb, orientations: list[str], heads: list[int]) -> 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 == "되메우기 <A&B>" 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 "<table" in s["html"] for s in body["sheets"])
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 >= 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
@@ -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()
@@ -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
+178
View File
@@ -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"] # 총괄 공종 칸 세로 병합
+329
View File
@@ -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)
@@ -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
@@ -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
@@ -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 (
'["절토", "연암"]',
'["절토", "경암"]',
+3 -1
View File
@@ -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