Merge remote-tracking branch 'origin/CODEX' into CODEX
This commit is contained in:
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -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}`,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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"] => ({
|
||||
|
||||
@@ -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> = { "&": "&", "<": "<", ">": ">", '"': """ };
|
||||
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;
|
||||
});
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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_자재_셋_값_없는_줄_숨김():
|
||||
|
||||
@@ -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
|
||||
) # 칸 이름 = 모달과 같은 규칙
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_잇기():
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 하단 고정 줄 맨 아래 줄
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`))));",
|
||||
)
|
||||
|
||||
@@ -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_길이는_집계_수량에_안_실림():
|
||||
|
||||
@@ -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
|
||||
@@ -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"] # 총괄 공종 칸 세로 병합
|
||||
@@ -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 (
|
||||
'["절토", "연암"]',
|
||||
'["절토", "경암"]',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user