Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1
This commit is contained in:
@@ -569,7 +569,7 @@
|
||||
}
|
||||
|
||||
/* 상자 안 작은 단추 템플릿(54-41) — 높이 = 숫자 칸 [-][+](`--b05-control-h`) · 글 · 안 여백도 그 높이에 ·
|
||||
* 상자 이름표 [삭제]도 같은 틀(136-3) · 상자 밖 [추가] · [삭제] · [리셋]은 제외 */
|
||||
* 상자 이름표 [삭제]도 같은 틀(136-3) · 상자 밖 [추가] · [삭제] · [리셋]은 아래 205-1 */
|
||||
.b05-structure__group-body button:not(.b05-structure__step),
|
||||
.b05-structure__group > legend > button {
|
||||
box-sizing: border-box;
|
||||
@@ -580,6 +580,24 @@
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 「구조물 배치」 맨 아래 [추가] · [삭제] · [리셋] 줄(205-1) — 단추 높이 = 상자 안 작은 단추(`--b05-control-h`) ·
|
||||
* 폼이 길어도 스크롤 영역 아래 끝에 붙어 늘 보임(자리 고정 · 뒤 내용이 비치지 않게 불투명 배경) */
|
||||
.b05-structure-section .b05-route__irregular-actions {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 1;
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.b05-structure-section .b05-route__irregular-actions > button {
|
||||
box-sizing: border-box;
|
||||
height: var(--b05-control-h);
|
||||
min-height: 0;
|
||||
padding-block: 0;
|
||||
font-size: var(--text-caption);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 노면공 「확폭 구간」 상자(PLAN 87-3) — 구간마다 단추 한 줄 */
|
||||
.b05-structure__widening[hidden] {
|
||||
display: none;
|
||||
|
||||
@@ -121,7 +121,11 @@ class _SectionGeometry:
|
||||
# ⚠ 옛 저장분(`ditch_choice` 가 없던 것)은 `legacy_ditch_enabled` 로 온다. 그 값은
|
||||
# **자동값과 다를 때만 뜻이 있다** — 같으면 자동이 그렇게 냈던 것이고, 다르면
|
||||
# 사용자가 일부러 바꾼 것이다. 그래서 **다를 때만** 선택으로 살린다(설계 의도 보존).
|
||||
if section_mode == "both_fill":
|
||||
# ⚠ 양측 성토는 **판정된 역할**로 가른다(206-1) — 입력 `section_mode` 는 옛 저장값일 수
|
||||
# 있다(계획고를 올려 우 절토 → 양측 성토 · 입력은 right_cut 그대로). 입력으로 가르면 옛
|
||||
# 측구 결과가 선택으로 둔갑해 양측 성토 단면에 측구가 섰다. ⚠ TS 짝 `SectionGeometry`.
|
||||
both_fill = self.left_role == "fill" and self.right_role == "fill"
|
||||
if both_fill:
|
||||
auto_ditch = False
|
||||
elif ground_at is not None:
|
||||
ditch_edge = self.left_extent if ditch_side == "left" else -self.right_extent
|
||||
@@ -136,10 +140,10 @@ class _SectionGeometry:
|
||||
# 없는데, 그것을 선택으로 굳히면 **다시 같은 병**(지형이 바뀌어도 안 따라감)이 된다.
|
||||
if choice is not None and bool(choice) == auto_ditch:
|
||||
choice = None
|
||||
# 양성(both_fill)은 측구가 설 자리가 없다 — 선택보다 기하가 먼저다.
|
||||
self.has_ditch = auto_ditch if (choice is None or section_mode == "both_fill") else choice
|
||||
# 양성(both_fill)은 측구가 설 자리가 없다 — 선택보다 기하가 먼저 · 남은 선택은 비움.
|
||||
self.has_ditch = auto_ditch if (choice is None or both_fill) else choice
|
||||
#: 그 측점에 **사용자가 정한 선택**(없으면 자동). 결과와 갈라 내보낸다.
|
||||
self.ditch_choice = choice
|
||||
self.ditch_choice = None if both_fill else choice
|
||||
|
||||
# 설계선 쉬프트 = 쪽마다 사면 이동(≥ 0 · 139-6 · 151-2) — 단면유형 · 측구 판정은 **옮기기 전**
|
||||
# 노견에서(위) · 노견(폭 고정) · 측구 · 사면이 같이 바깥으로 · 늘어난 폭은 차도.
|
||||
|
||||
@@ -388,8 +388,10 @@ export function buildDesignControls(
|
||||
groundSegment.classList.add("b06-design__seg--header");
|
||||
// 단면 유형은 지형에서 자동 판정되며(D-2), 제목행 pill로 표시한다(E-3, Cross_View에서 생성).
|
||||
// 아래 옵션은 선행 조건 미충족 시 비활성 = 숨김(179-2 · CSS) — 편측의 쪽은 알약 글에.
|
||||
const rockCut = isRock(state.ground) && state.mode !== "both_fill";
|
||||
const hasDitch = state.mode !== "both_fill";
|
||||
// 숨김은 「막힘 + 그려진 측구 없음」일 때만(206-1) — 측구가 그려져 있으면 단추를 보여 끌 수 있게.
|
||||
const ditchDrawn = design?.ditch_enabled === true;
|
||||
const rockCut = isRock(state.ground) && (state.mode !== "both_fill" || ditchDrawn);
|
||||
const hasDitch = state.mode !== "both_fill" || ditchDrawn;
|
||||
// 측구 생성 여부(자동 판정 or 사용자 override) — 측구형식은 측구가 있을 때만 의미 있다.
|
||||
// 화면 표시는 **결과**를 보인다 — 선택이 없으면 자동으로 선 결과가 답이다.
|
||||
const ditchOn = state.ditchEnabled ?? design?.ditch_enabled ?? true;
|
||||
|
||||
@@ -41,10 +41,13 @@ function rows(warnings: FillSlopeWarning[], stationInterval: number): NoticeChip
|
||||
});
|
||||
}
|
||||
|
||||
export function createFillSlopeNotice(onPick: (stationId: string) => void): FillSlopeNotice {
|
||||
export function createFillSlopeNotice(
|
||||
onPick: (stationId: string) => void,
|
||||
selected: () => string | null,
|
||||
): FillSlopeNotice {
|
||||
const root = document.createElement("div");
|
||||
root.className = "b06-section__notice-bar";
|
||||
const chips = createNoticeChips(onPick);
|
||||
const chips = createNoticeChips(onPick, selected);
|
||||
root.append(chips.root);
|
||||
|
||||
return {
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* 횡단 알림 줄 칩 꼴(196-1) — 항목 = 짧은 이름 + 측점 수 칩 한 줄 · 긴 설명 = 공용 호버.
|
||||
* 줄 아래 내용 칸 하나를 같이 씀 — 누른 칩의 측점 목록만 · 같은 칩 다시 누르면 닫힘 ·
|
||||
* 다른 칩 누르면 바뀜(한 번에 하나). 측점 단추 누름 = `onPick`(그 카드로).
|
||||
* ⭐ 207-1 — 내용 칸은 줄 아래 카드 위에 겹쳐 뜸(흐름 밖 · CSS) · 측점 누르면 접힘 ·
|
||||
* 열 때 지금 고른 측점(`selected`) 단추 강조.
|
||||
* 무엇을 셀지는 부르는 쪽(`_FillSlope_Notice`) — 여기는 그리기만.
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -29,7 +31,10 @@ export interface NoticeChips {
|
||||
update: (items: NoticeChipItem[]) => void;
|
||||
}
|
||||
|
||||
export function createNoticeChips(onPick: (stationId: string) => void): NoticeChips {
|
||||
export function createNoticeChips(
|
||||
onPick: (stationId: string) => void,
|
||||
selected: () => string | null = () => null,
|
||||
): NoticeChips {
|
||||
const root = document.createElement("div");
|
||||
root.className = "b06-section__notice-blocks";
|
||||
const chips = document.createElement("div");
|
||||
@@ -57,12 +62,14 @@ export function createNoticeChips(onPick: (stationId: string) => void): NoticeCh
|
||||
);
|
||||
panel.hidden = !open;
|
||||
panel.classList.toggle("is-info", open?.tone === "info");
|
||||
const picked = open ? selected() : null;
|
||||
panel.replaceChildren(
|
||||
...(open?.rows ?? []).map((row) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = row.text;
|
||||
button.dataset.stationId = row.stationId;
|
||||
button.classList.toggle("is-selected", row.stationId === picked);
|
||||
return button;
|
||||
}),
|
||||
);
|
||||
@@ -77,7 +84,10 @@ export function createNoticeChips(onPick: (stationId: string) => void): NoticeCh
|
||||
});
|
||||
panel.addEventListener("click", (event) => {
|
||||
const id = (event.target as HTMLElement).closest("button")?.dataset.stationId;
|
||||
if (id) onPick(id);
|
||||
if (!id) return;
|
||||
openKey = null;
|
||||
render();
|
||||
onPick(id);
|
||||
});
|
||||
attachHoverInfo(
|
||||
chips,
|
||||
|
||||
@@ -106,6 +106,8 @@ interface StaleCandidate {
|
||||
geometry_preset?: string;
|
||||
two_stage_slope?: boolean;
|
||||
surface_drop_m?: number;
|
||||
section_mode?: string;
|
||||
ditch_enabled?: boolean;
|
||||
} | null;
|
||||
}
|
||||
|
||||
@@ -120,6 +122,8 @@ function isStaleSection(
|
||||
// 이 조건이 B06 페이지에만 있어 B05는 재계산을 건너뛰었고, 같은 데이터인데 두 화면의
|
||||
// 절토량이 갈렸다(2026-09-03 실측 228.52㎡ ↔ 270.12㎡). 판정을 여기 한 곳으로 모은다.
|
||||
if (design.geometry_preset === "rock" && design.two_stage_slope === undefined) return true;
|
||||
// 옛 엔진이 양측 성토에 측구를 세워 둔 저장분(206-1) — 지금 엔진으로 다시 셈.
|
||||
if (design.section_mode === "both_fill" && design.ditch_enabled === true) return true;
|
||||
if (!profiles?.length) return false;
|
||||
const planned = designElevationAt(profiles, section.chainage_m);
|
||||
if (planned === undefined) return false;
|
||||
|
||||
@@ -124,8 +124,9 @@ export function createSectionView(
|
||||
} = { balloonScope: "-" };
|
||||
|
||||
// 성토사면 5m 초과 경고 줄(2026-09-14 브레인 (나)) — 카드 위 · 측점 누르면 그 카드로.
|
||||
const fillSlopeNotice = createFillSlopeNotice((id) =>
|
||||
selectedStationId === id ? revealCard(id, "smooth") : selectStation(id, true),
|
||||
const fillSlopeNotice = createFillSlopeNotice(
|
||||
(id) => (selectedStationId === id ? revealCard(id, "smooth") : selectStation(id, true)),
|
||||
() => selectedStationId,
|
||||
);
|
||||
// 열 수 칸(138-3) — 고정 알림 줄 오른쪽 끝 · 1~6 · 바꾸면 카드 전부 다시 그림
|
||||
const columnsField = createInputField({
|
||||
|
||||
@@ -172,9 +172,10 @@
|
||||
}
|
||||
|
||||
.b06-section__notice-chip {
|
||||
padding: 0 var(--spacing-8);
|
||||
padding: 2px var(--spacing-8);
|
||||
border: 1px solid currentColor;
|
||||
border-radius: var(--radius-sm);
|
||||
/* 둥근 사각(207-1) — 공용 단추 반경 · 알약 아님 */
|
||||
border-radius: var(--radius-buttons);
|
||||
background: transparent;
|
||||
color: var(--color-warning);
|
||||
font-size: var(--text-caption);
|
||||
@@ -193,14 +194,24 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 내용 칸 하나(누른 칩의 측점 목록) — 길면 줄 안에서 스크롤 · 고정 줄이 카드 칸을 다 덮지 않게 */
|
||||
/* 내용 칸 하나(누른 칩의 측점 목록) — 길면 칸 안에서 스크롤 · 고정 줄이 카드 칸을 다 덮지 않게.
|
||||
207-1 — 줄 아래 카드 위에 겹쳐 뜸(흐름 밖) · 열고 닫아도 줄 높이 · 카드 자리 그대로 */
|
||||
.b06-section__notice-list {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-4);
|
||||
max-height: 30vh;
|
||||
overflow: auto;
|
||||
padding-top: var(--spacing-4);
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface-raised);
|
||||
box-shadow: var(--shadow-lg);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
@@ -213,6 +224,13 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 지금 고른 측점(207-1) — 카드 고름 색과 같음 */
|
||||
.b06-section__notice-list > button.is-selected {
|
||||
outline: 2px solid var(--color-danger);
|
||||
outline-offset: -1px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.b06-cross-card {
|
||||
cursor: pointer;
|
||||
transition:
|
||||
|
||||
@@ -40,6 +40,7 @@ export function renderEarthworkSummary(host: HTMLElement, table: EarthworkTable)
|
||||
note: row.note || " ",
|
||||
})),
|
||||
frozen: 1,
|
||||
mergeSame: ["group", "spec"], // 201-1 같은 구분 · 규격 이어지면 세로 병합(빈칸은 안 합침)
|
||||
caption: "토공집계표",
|
||||
fillViewport: true,
|
||||
});
|
||||
|
||||
@@ -109,6 +109,8 @@ interface Table {
|
||||
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 {
|
||||
@@ -218,10 +220,10 @@ 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, i) => {
|
||||
const rowsOut: DataRow[] = keep.map((at) => {
|
||||
const row = rows[at];
|
||||
const out: DataRow = {
|
||||
name: i > 0 && rows[keep[i - 1]].name === row.name ? "" : row.name,
|
||||
name: row.name, // 위 줄과 같으면 세로 병합(201-1)
|
||||
spec: row.spec,
|
||||
qty: row.qty,
|
||||
};
|
||||
@@ -256,6 +258,7 @@ function concreteTable(s: MaterialSheets, o: MaterialOverrides): Table {
|
||||
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)],
|
||||
};
|
||||
@@ -275,10 +278,10 @@ function totalTable(s: MaterialSheets, o: MaterialOverrides): Table {
|
||||
numberColumn({ key: "amount", label: "합계", width: 96 }),
|
||||
{ key: "note", label: "비고", width: 96 },
|
||||
],
|
||||
rows: keep.map((at, i) => {
|
||||
rows: keep.map((at) => {
|
||||
const row = rows[at];
|
||||
return {
|
||||
group: i > 0 && rows[keep[i - 1]].group === row.group ? "" : row.group,
|
||||
group: row.group, // 위 줄과 같으면 세로 병합(201-1)
|
||||
spec: row.spec,
|
||||
unit: row.unit,
|
||||
qty: row.qty,
|
||||
@@ -295,6 +298,7 @@ function totalTable(s: MaterialSheets, o: MaterialOverrides): Table {
|
||||
}),
|
||||
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],
|
||||
@@ -364,6 +368,7 @@ export async function renderMaterialSheet(
|
||||
rows: built.rows,
|
||||
total: built.total,
|
||||
frozen: built.frozen,
|
||||
mergeSame: built.mergeSame,
|
||||
empty: "",
|
||||
caption: BASIS,
|
||||
fillViewport: true,
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""B09 설계내역서 연결 — 서고 구조물조합 · 일위대가 사전조합 → 프로젝트 사본(PLAN 204-2).
|
||||
|
||||
자리 = 프로젝트 `B09_Estimation/v1/library/{폴더}/` · 폴더 = `{종류}@{층}@{주인}@{키}`
|
||||
(주인 `/` → `-` · 마스터 `-`) — 같은 것을 다시 고르면 새로 안 들여오고 있는 사본(같은 호표):
|
||||
combo 구조물조합 = B08 구조물 사본과 한 꼴(`M02_Item_Snapshot.build_snapshot` — 집계 ·
|
||||
도면 · 면적 · 일위대가 · 단가 · 입력값 · 고른자재 첫 값)
|
||||
precombo 사전조합 = `일위대가.json` {줄: {담은로직: [{참조, 수량: {손: 1}}]}, 참조사본} ·
|
||||
`단가.json`(`M02_Item_Source.price_bundle` — 사본 단가 묶음 한 벌)
|
||||
사본 = 원본이 바뀌거나 지워져도 그대로(굳힘) ·
|
||||
대가 블록 = `Labor_Source.daega_inputs`(`libs` {폴더: 블록 id}) ·
|
||||
단가 수요 = `Linked.linked_resource_keys` · 줄 연결 = {tab: dg, key: 블록 id, lib: 폴더} ·
|
||||
[저장] 때 줄이 안 쓰는 사본은 지움(`prune`) · 층 읽기 권한 = 부르는 쪽(`M02_Layer_Access.grant`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B09_Estimation.B09_Estimation_Store import folder as store_folder
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete.M02_Item_Snapshot import MANIFEST_NAME, build_snapshot, now_text, origin_of
|
||||
from M02_MasterTemplete.M02_Item_Source import (
|
||||
LABOR_FILE,
|
||||
PRICE_FILE,
|
||||
item_name,
|
||||
labor_doc_rows,
|
||||
price_bundle,
|
||||
read_json,
|
||||
)
|
||||
|
||||
LIBRARY = "library"
|
||||
KINDS = ("combo", "precombo")
|
||||
#: 폴더 이름 — 종류 · 층 · 주인 · 키(영문 · 숫자 · `-` `_`)
|
||||
NAME = re.compile(r"^(combo|precombo)@(system|company|personal)@[0-9-]+@[A-Za-z0-9_-]{1,40}$")
|
||||
|
||||
|
||||
def library_dir(project_root: str | Path) -> Path:
|
||||
return store_folder(project_root) / LIBRARY
|
||||
|
||||
|
||||
def folder_name(kind: str, layer: str, owner: str | None, key: str) -> str:
|
||||
"""고른 서고 것 → 사본 폴더 이름 — 규칙 밖이면 ValueError."""
|
||||
name = f"{kind}@{layer}@{(owner or '-').replace('/', '-')}@{key}"
|
||||
if not NAME.match(name):
|
||||
raise ValueError(f"서고 항목 모양이 틀림 「{name}」")
|
||||
return name
|
||||
|
||||
|
||||
def _precombo_files(layer: str, owner: str | None, key: str) -> dict[str, Any]:
|
||||
ref = {"층": layer, "주인": owner, "키": key}
|
||||
doc = refs.read_ref(ref)
|
||||
if doc is None or doc.get("종류") != refs.TITLE["precombo"]:
|
||||
raise LookupError(f"사전조합 {key} 이 {layer} 층에 없음")
|
||||
line = {"참조": ref, "수량": {"손": 1}}
|
||||
labor = labor_doc_rows({"이름": doc.get("이름"), "단위": doc.get("단위"), "담은로직": [line]})
|
||||
return {
|
||||
LABOR_FILE: labor,
|
||||
PRICE_FILE: price_bundle(labor, refs.folder_of(layer, owner)),
|
||||
"_원본": {"참조": ref, "name": doc.get("이름") or key, "단위": doc.get("단위") or ""},
|
||||
}
|
||||
|
||||
|
||||
def import_copy(
|
||||
project_root: str | Path,
|
||||
kind: str,
|
||||
layer: str,
|
||||
owner: str | None,
|
||||
key: str,
|
||||
who: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""서고 것 하나를 프로젝트 사본으로 — `{폴더, 만듦}` · 이미 있으면 그대로(만듦 거짓) ·
|
||||
원본이 없으면 LookupError · 모양이 틀리면 ValueError."""
|
||||
if kind not in KINDS:
|
||||
raise ValueError(f"서고 종류는 {', '.join(KINDS)} 중 하나")
|
||||
name = folder_name(kind, layer, owner, key)
|
||||
target = library_dir(project_root) / name
|
||||
if isinstance(read_json(target / MANIFEST_NAME), dict):
|
||||
return {"폴더": name, "만듦": False}
|
||||
if kind == "combo":
|
||||
item = {"layer": layer, "owner": owner, "key": key}
|
||||
item["name"] = item_name(item)
|
||||
files, origin = build_snapshot(item), origin_of(item)
|
||||
else:
|
||||
files = _precombo_files(layer, owner, key)
|
||||
origin = files.pop("_원본")
|
||||
if target.is_dir(): # 끊긴 쓰기(manifest 없음) 자리
|
||||
shutil.rmtree(target)
|
||||
target.mkdir(parents=True)
|
||||
for file, content in files.items():
|
||||
atomic_write_json(target / file, content)
|
||||
stamp = now_text()
|
||||
manifest = {"종류": kind, "원본": origin, "가져온날": stamp, "가져온이": who}
|
||||
atomic_write_json(target / MANIFEST_NAME, manifest) # 마지막 — 있으면 다 쓴 폴더
|
||||
return {"폴더": name, "만듦": True}
|
||||
|
||||
|
||||
def copies(project_root: str | Path) -> list[tuple[Path, dict[str, Any]]]:
|
||||
"""다 쓴 사본 `(폴더, manifest)` — 폴더 이름 차례."""
|
||||
base = library_dir(project_root)
|
||||
out = []
|
||||
for child in sorted(base.iterdir()) if base.is_dir() else []:
|
||||
manifest = read_json(child / MANIFEST_NAME)
|
||||
if child.is_dir() and NAME.match(child.name) and isinstance(manifest, dict):
|
||||
out.append((child, manifest))
|
||||
return out
|
||||
|
||||
|
||||
def used_folders(user: dict[str, Any]) -> set[str]:
|
||||
"""설계내역서 덮개 줄이 잇는 서고 사본 폴더."""
|
||||
rows = ((user or {}).get("bill") or {}).get("rows") or []
|
||||
return {
|
||||
str(ref["lib"])
|
||||
for row in rows
|
||||
if isinstance(row, dict)
|
||||
and isinstance(ref := row.get("ref"), dict)
|
||||
and ref.get("tab") == "dg"
|
||||
and ref.get("lib")
|
||||
}
|
||||
|
||||
|
||||
def prune(project_root: str | Path, user: dict[str, Any]) -> list[str]:
|
||||
"""[저장] 뒤 — 줄이 안 잇는 사본 폴더를 지움(다시 고르면 새로 들여옴) · 지운 이름."""
|
||||
keep = used_folders(user)
|
||||
base = library_dir(project_root)
|
||||
gone = []
|
||||
for child in sorted(base.iterdir()) if base.is_dir() else []:
|
||||
if child.is_dir() and NAME.match(child.name) and child.name not in keep:
|
||||
shutil.rmtree(child)
|
||||
gone.append(child.name)
|
||||
return gone
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
⚠ 짝 = `B09_Estimation_Calc_Bill.ts`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 ·
|
||||
거울 시험 `resources/tester/test_145_3_b09_bill.py` · `test_182_b09_bill_level.py`(레벨) ·
|
||||
`test_184_2_b09_bill_free.py`(자유 편집).
|
||||
`test_184_2_b09_bill_free.py`(자유 편집) · `test_204_1_b09_bill_drag.py`(묶음 자동).
|
||||
|
||||
실무 엑셀 설계내역서 꼴 · 기본 공종 나무(초기값) = 엑셀 꼴(`SECTIONS` · 공구 안 나눔):
|
||||
1 토공사(1.1~1.6) · 2 구조물공(2.1~2.3) · 3 운반공 · 4 자재대 · 관급자재대(제외금액)
|
||||
@@ -33,7 +33,9 @@
|
||||
`rows` 에 없고 `gone` 에도 없는 자동 줄(B08 새 열 · 대비표 새 표시) =
|
||||
기본 부모 끝 + 비고 「새 자동 줄」(기본 부모가 없으면 표 끝 맨 위) ·
|
||||
원천이 안 낸 키 = 안 보임 ·
|
||||
부모 못 찾음 · 묶음 아님 · 돎 = 자동 줄 기본 부모 / 새 줄 맨 위 · `sec:G` = 맨 위 고정 ·
|
||||
부모 못 찾음 · 자재 줄(`mat:`) · 돎 = 자동 줄 기본 부모 / 새 줄 맨 위 · `sec:G` = 맨 위 고정 ·
|
||||
항목 줄에 자식이 생김 = 저절로 묶음(204 · 금액 = 자손 합 · 제 수량 · 연결은 덮개에 남음 —
|
||||
자식이 다 빠지면 다시 항목) ·
|
||||
관급 자재 줄 = `sec:G` 밖 못 감(밖이면 `sec:G` 아래) · 깊이 5 넘음 = 레벨 4 조상 아래.
|
||||
옛 꼴(v 없음 · 166-2 · 182) = `qty{줄 키: 수}` 수량 덮개 + `rows[]` 손 줄(`section` ·
|
||||
`after` 앞 줄 뒤에 끼움 · `parent` = 같은 공종 손 묶음) — 그대로 읽음(화면 첫 손질 때 v2).
|
||||
@@ -331,7 +333,7 @@ def _order_v2(bill: dict, titles: list[dict], items: list[dict], hands: list[tup
|
||||
|
||||
|
||||
def _tree(order: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""부모 바로잡기(없음 · 항목 · 돎 = 기본 부모 / 맨 위 · `sec:G` 맨 위 ·
|
||||
"""부모 바로잡기(없음 · 자재 줄 · 돎 = 기본 부모 / 맨 위 · `sec:G` 맨 위 ·
|
||||
관급 자재 줄 = `sec:G` 안) → 표 차례(부모 먼저 · 형제 = 저장 차례) · level · cost · section
|
||||
(깊이 5 넘음 = 레벨 4 조상 아래)."""
|
||||
by_key = {row["key"]: row for row in order}
|
||||
@@ -353,7 +355,7 @@ def _tree(order: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
up = by_key.get(row.get("parent") or "")
|
||||
if row["key"] == "sec:G":
|
||||
row["parent"] = None
|
||||
elif up is None or up["kind"] == "item" or up is row:
|
||||
elif up is None or up["key"].startswith("mat:") or up is row:
|
||||
row["parent"] = row["_home"] if row["_home"] in by_key else None
|
||||
for row in order:
|
||||
if loops(row):
|
||||
@@ -383,6 +385,11 @@ def _tree(order: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
row["section"] = chain[-1]["section"] if chain else None
|
||||
rows.append(row)
|
||||
stack.extend((kid, [*chain, row]) for kid in reversed(kids.get(row["key"], [])))
|
||||
parents = {row["parent"] for row in rows}
|
||||
for row in rows: # 자식이 생긴 항목 = 저절로 묶음(204)
|
||||
if row["kind"] == "item" and row["key"] in parents:
|
||||
row.update({"kind": "group", "qty": None, "ref": None, "_gaps": []})
|
||||
row.update({f"price_{p}": None for p in (*PARTS, "sum")})
|
||||
return rows
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* B09 설계내역서 셈(PLAN 145-3) — 화면 없음 · 저장 안 함 · import 는 짝 바탕 셈 하나(시험이 이어 붙임).
|
||||
*
|
||||
* ⚠ 짝 = `B09_Estimation_Calc_Bill.py`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 ·
|
||||
* 거울 시험 `resources/tester/test_145_3_b09_bill.py` · `test_182_b09_bill_level.py`(레벨) · `test_184_2_b09_bill_free.py`(자유 편집).
|
||||
* 거울 시험 `resources/tester/test_145_3_b09_bill.py` · `test_182_b09_bill_level.py`(레벨) · `test_184_2_b09_bill_free.py`(자유 편집) ·
|
||||
* `test_204_1_b09_bill_drag.py`(묶음 자동).
|
||||
*
|
||||
* 공종 나무 = 엑셀 꼴 고정(1 토공사 · 2 구조물공 · 3 운반공 · 4 자재대 · 관급자재대(제외금액)) · 공구 안 나눔
|
||||
* 항목 = 서버가 B08 수량으로 짠 줄 + 대비표 「별도」(4 자재대) · 「관급」(관급자재대) 자재 + 조달수수료
|
||||
@@ -304,7 +305,8 @@ function orderV2(bill: Row, titles: Row[], items: Row[], hands: Hands): Row[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 부모 바로잡기(없음 · 항목 · 돎 = 기본 부모 / 맨 위 · sec:G 맨 위 · 관급 자재 줄 = sec:G 안) →
|
||||
/** 부모 바로잡기(없음 · 자재 줄 · 돎 = 기본 부모 / 맨 위 · sec:G 맨 위 · 관급 자재 줄 = sec:G 안) →
|
||||
* 자식이 생긴 항목 = 저절로 묶음(204 · 자식이 다 빠지면 다시 항목) →
|
||||
* 표 차례(부모 먼저 · 형제 = 저장 차례) · level · cost · section(깊이 5 넘음 = 레벨 4 조상 아래) */
|
||||
function tree(order: Row[]): Row[] {
|
||||
const byKey = new Map<string, Row>();
|
||||
@@ -325,7 +327,7 @@ function tree(order: Row[]): Row[] {
|
||||
for (const row of order) {
|
||||
const up = byKey.get(row.parent ?? "");
|
||||
if (row.key === "sec:G") row.parent = null;
|
||||
else if (!up || up.kind === "item" || up === row) row.parent = home(row);
|
||||
else if (!up || up.key.startsWith("mat:") || up === row) row.parent = home(row);
|
||||
}
|
||||
for (const row of order) {
|
||||
if (!loops(row)) continue;
|
||||
@@ -354,6 +356,13 @@ function tree(order: Row[]): Row[] {
|
||||
rows.push(row);
|
||||
for (const kid of [...(kids.get(row.key) || [])].reverse()) stack.push([kid, [...chain, row]]);
|
||||
}
|
||||
const parents = new Set(rows.map((row) => row.parent));
|
||||
for (const row of rows) {
|
||||
// 자식이 생긴 항목 = 저절로 묶음(204)
|
||||
if (row.kind !== "item" || !parents.has(row.key)) continue;
|
||||
Object.assign(row, { kind: "group", qty: null, ref: null, _gaps: [] });
|
||||
for (const p of [...PARTS, "sum"]) row[`price_${p}`] = null;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B09_Estimation import B09_Estimation_Bill_Library as library
|
||||
from B09_Estimation import B09_Estimation_Sangeun as sg
|
||||
from B09_Estimation import B09_Estimation_Sangeun_Text as st
|
||||
from M02_MasterTemplete import M02_Item_Prices as item_prices
|
||||
@@ -282,6 +283,19 @@ class Builder:
|
||||
}
|
||||
)
|
||||
|
||||
def library(self, folder: Path, manifest: dict[str, Any]) -> str | None:
|
||||
"""설계내역서 서고 사본(204-2) — 구조물조합 = 조합 블록 · 사전조합 = 겹 대가 블록 그대로."""
|
||||
if manifest.get("종류") != "precombo":
|
||||
return self.combo(folder)
|
||||
labor = read_json(folder / LABOR_FILE)
|
||||
ref = (manifest.get("원본") or {}).get("참조")
|
||||
copies = (labor or {}).get("참조사본") or {}
|
||||
doc = copies.get(refs.ref_id(ref)) if isinstance(ref, dict) else None
|
||||
if not isinstance(doc, dict):
|
||||
return None
|
||||
whole = item_prices.load_prices(read_json(folder / PRICE_FILE) or {})
|
||||
return self.precombo(whole, ref, doc, copies, 1)
|
||||
|
||||
|
||||
def _material(whole: Any, mat: dict, qty: Any) -> dict[str, Any]:
|
||||
"""자재 줄 — 조합 사용자 자재 · 사전조합 자재(175-2) 한 꼴 · 이름 · 규격 · 단위 = 사본 단가."""
|
||||
@@ -327,9 +341,11 @@ def _plain(value: Any) -> Any:
|
||||
|
||||
|
||||
def daega_inputs(project_root: str | Path) -> dict[str, Any]:
|
||||
"""대가 셈 입력 — `{blocks: [...], owners: {구조물 id: 블록 id}}` · 사본 없으면 빈 것.
|
||||
"""대가 셈 입력 — `{blocks: [...], owners: {구조물 id: 블록 id}}` · 사본 없으면 빈 것 ·
|
||||
설계내역서 서고 사본(204-2)이 있으면 `libs` {폴더: 블록 id}.
|
||||
|
||||
차례 = 조합(구조물 id 차례) → 사전조합 → 일위대가화(품셈 · 처음 본 차례) — 호표 번호 = 이 차례.
|
||||
차례 = 조합(구조물 id 차례 → 서고 사본) → 사전조합 → 일위대가화(품셈 · 처음 본 차례) —
|
||||
호표 번호 = 이 차례.
|
||||
"""
|
||||
base = structures_dir(project_root)
|
||||
build = Builder()
|
||||
@@ -343,6 +359,15 @@ def daega_inputs(project_root: str | Path) -> dict[str, Any]:
|
||||
bid = None # 한 사본이 막혀도 나머지 블록은 셈 — 그 구조물은 없음
|
||||
if bid:
|
||||
owners[owner] = bid
|
||||
libs: dict[str, str] = {}
|
||||
for folder, manifest in library.copies(project_root):
|
||||
try:
|
||||
bid = build.library(folder, manifest)
|
||||
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError):
|
||||
bid = None
|
||||
if bid:
|
||||
libs[folder.name] = bid
|
||||
order = {"조합": 0, "사전조합": 1, "품셈": 2}
|
||||
blocks = sorted(build.blocks.values(), key=lambda b: order[b["kind"]])
|
||||
return _plain({"blocks": blocks, "owners": owners})
|
||||
out = {"blocks": blocks, "owners": owners}
|
||||
return _plain({**out, "libs": libs} if libs else out)
|
||||
|
||||
@@ -67,13 +67,19 @@ def used_keys(folder: Path) -> list[str]:
|
||||
|
||||
|
||||
def linked_resource_keys(project_root: str | Path) -> list[str]:
|
||||
"""연결된 사본 전부가 부른 자원 키 — 구조물 id 차례 · 겹침 없음."""
|
||||
"""연결된 사본 전부가 부른 자원 키 — 구조물 id 차례 → 설계내역서 서고 사본(204-2) ·
|
||||
겹침 없음."""
|
||||
from B09_Estimation.B09_Estimation_Bill_Library import copies
|
||||
|
||||
base = structures_dir(project_root)
|
||||
folders = [
|
||||
base / linked["폴더"]
|
||||
for _owner, linked in sorted(linked_snapshots(project_root).items())
|
||||
if "일위대가" in linked.get("부분", [])
|
||||
]
|
||||
out: list[str] = []
|
||||
for _owner, linked in sorted(linked_snapshots(project_root).items()):
|
||||
if "일위대가" not in linked.get("부분", []):
|
||||
continue
|
||||
for key in used_keys(base / linked["폴더"]):
|
||||
for folder in [*folders, *(f for f, _ in copies(project_root))]:
|
||||
for key in used_keys(folder):
|
||||
if key not in out:
|
||||
out.append(key)
|
||||
return out
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
@@ -197,6 +198,23 @@ def refresh_book(keys: list[str]) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def book_stale(book: dict[str, Any] | None, keys: list[str]) -> str | None:
|
||||
"""203-1 [업데이트] 배지 — 지금 M01 로 새로 굳힌 책(`refresh_book`)과 견줌 · 다른 것 한 줄(같으면 None)."""
|
||||
if book is None:
|
||||
return "단가 아직 안 굳힘"
|
||||
fresh = json.loads(json.dumps(refresh_book(keys), ensure_ascii=False)) # 저장 꼴(JSON)로 견줌
|
||||
rows = book.get("rows") or {}
|
||||
new = sum(key not in rows for key in fresh["rows"])
|
||||
changed = sum(key in rows and rows[key] != row for key, row in fresh["rows"].items())
|
||||
parts = [
|
||||
f"새 단가 줄 {new}" if new else "",
|
||||
f"M01 에서 바뀐 단가 {changed}" if changed else "",
|
||||
"운전사 계수 바뀜" if book.get("driver_factor") != fresh["driver_factor"] else "",
|
||||
"환율 바뀜" if book.get("fx") != fresh["fx"] else "",
|
||||
]
|
||||
return " · ".join(p for p in parts if p) or None
|
||||
|
||||
|
||||
def extend_book(book: dict[str, Any] | None, keys: list[str]) -> dict[str, Any]:
|
||||
"""[저장] — 책에 없는 새 줄만 더함(있는 줄 · 계수 · 환율은 안 바꿈) · 책이 없으면 새로 굳힘."""
|
||||
if book is None:
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
[저장] — 사용자 값 · 책에 없는 새 단가 줄만 굳힘 → 서버가 정본으로 다시 셈
|
||||
POST /api/projects/{id}/estimation/prices
|
||||
[단가 갱신] — 수요 단가 전부를 지금 M01 값으로 새로 굳힘
|
||||
POST /api/projects/{id}/estimation/library {kind, layer, owner, key}
|
||||
설계내역서 연결 고르기(204-2) — 서고 구조물조합 · 사전조합을 프로젝트 사본으로
|
||||
(있으면 그대로) → {폴더, 만듦, key = 대가 블록 id, payload} ·
|
||||
층 읽기 권한 = `M02_Layer_Access` · 줄 연결은 화면 캐시 → [저장] 때 안 쓰는 사본 지움(`Bill_Library.prune`)
|
||||
|
||||
저장 자리 = 프로젝트 안 `B09_Estimation/v1/`(`B09_Estimation_Store`) ·
|
||||
단가 = `B09_Estimation_Prices` ·
|
||||
@@ -20,11 +24,13 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Body
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from B08_Quantity.B08_Quantity_Router_Earthwork import earthwork_table
|
||||
from B08_Quantity.B08_Quantity_Router_Hydro import _project_root
|
||||
from B08_Quantity.B08_Quantity_Router_Material import material_sheets
|
||||
from B09_Estimation import B09_Estimation_Bill_Library as library
|
||||
from B09_Estimation import B09_Estimation_Prices as prices
|
||||
from B09_Estimation import B09_Estimation_Sangeun as sangeun
|
||||
from B09_Estimation import B09_Estimation_Store as store
|
||||
@@ -38,6 +44,9 @@ from B09_Estimation.B09_Estimation_Linked import linked_resource_keys
|
||||
from B09_Estimation.B09_Estimation_Rates import rate_candidates, rate_table
|
||||
from B09_Estimation.B09_Estimation_Router_Quotes import router as quotes_router
|
||||
from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines, used_materials
|
||||
from common_util.common_util_auth import verify_session
|
||||
from M02_MasterTemplete import M02_Layer_Access as access
|
||||
from M02_MasterTemplete.M02_Item_Snapshot import importer
|
||||
|
||||
router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"])
|
||||
|
||||
@@ -121,6 +130,8 @@ async def payload(project_id: UUID, root: Path) -> dict[str, Any]:
|
||||
"inputs": inputs,
|
||||
"user": user,
|
||||
"book": {"made": book.get("made")} if book else None,
|
||||
# 203-1 [업데이트] 배지 까닭(지금 M01 과 다른 것 한 줄 · 같으면 None)
|
||||
"stale": await asyncio.to_thread(prices.book_stale, book, keys),
|
||||
"sheets": sheets,
|
||||
"rate_candidates": rate_candidates(),
|
||||
"rate_table": rate_table(),
|
||||
@@ -139,7 +150,8 @@ async def estimation(project_id: UUID) -> dict[str, Any]:
|
||||
@router.put("/{project_id}/estimation")
|
||||
async def save_estimation(project_id: UUID, body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
root = await _project_root(project_id)
|
||||
await asyncio.to_thread(store.write_user, root, body.get("user"))
|
||||
user = await asyncio.to_thread(store.write_user, root, body.get("user"))
|
||||
await asyncio.to_thread(library.prune, root, user) # 204-2 줄이 안 잇는 서고 사본
|
||||
keys = await _demand(root)
|
||||
book = await asyncio.to_thread(store.read_book, root)
|
||||
book = await asyncio.to_thread(prices.extend_book, book, keys)
|
||||
@@ -156,5 +168,31 @@ async def refresh_prices(project_id: UUID) -> dict[str, Any]:
|
||||
return await payload(project_id, root)
|
||||
|
||||
|
||||
class LibraryBody(BaseModel):
|
||||
kind: str
|
||||
layer: str
|
||||
owner: str | None = None
|
||||
key: str
|
||||
|
||||
|
||||
@router.post("/{project_id}/estimation/library")
|
||||
async def import_library(
|
||||
project_id: UUID, body: LibraryBody, session: dict[str, Any] = Depends(verify_session)
|
||||
) -> dict[str, Any]:
|
||||
got = access.grant(session, body.layer, body.owner) # 못 읽는 층 = 403 · 회사 없음 = 409
|
||||
root = await _project_root(project_id)
|
||||
try:
|
||||
made = await asyncio.to_thread(
|
||||
library.import_copy, root, body.kind, got.layer, got.owner, body.key, importer(session)
|
||||
)
|
||||
except LookupError as error:
|
||||
raise HTTPException(status_code=404, detail=str(error)) from error
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||
out = await payload(project_id, root)
|
||||
key = ((out["inputs"].get("daega") or {}).get("libs") or {}).get(made["폴더"])
|
||||
return {**made, "key": key, "payload": out}
|
||||
|
||||
|
||||
# 166-4 견적서 파일 길 — 같은 머리 · 같은 권한
|
||||
router.include_router(quotes_router)
|
||||
|
||||
@@ -95,12 +95,14 @@ def _text(value: Any) -> str:
|
||||
|
||||
|
||||
def _clean_ref(ref: Any) -> dict[str, Any] | None:
|
||||
"""손 줄 연결 — dg · sg = 호표 키 · logic = M01 로직(입력값 · 켜쌓기율 · 고름)."""
|
||||
"""손 줄 연결 — dg · sg = 호표 키 · logic = M01 로직(입력값 · 켜쌓기율 · 고름) ·
|
||||
dg `lib` = 서고 사본 폴더(204-2 · [저장] 때 안 쓰는 사본 지움)."""
|
||||
if not isinstance(ref, dict):
|
||||
return None
|
||||
tab = ref.get("tab")
|
||||
if tab in ("dg", "sg") and isinstance(ref.get("key"), str) and ref["key"]:
|
||||
return {"tab": tab, "key": ref["key"]}
|
||||
lib = ref.get("lib") if tab == "dg" else None
|
||||
return {"tab": tab, "key": ref["key"], **({"lib": lib} if isinstance(lib, str) else {})}
|
||||
if tab == "logic" and isinstance(ref.get("code"), str) and ref["code"]:
|
||||
given = ref.get("given") if isinstance(ref.get("given"), dict) else {}
|
||||
picks = ref.get("picks") if isinstance(ref.get("picks"), dict) else None
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Check.ts
|
||||
* B09 좌측 「데이터 점검」 묶음(PLAN 203-1) — [업데이트] · 연결 점검(옛 「연결 점검」 탭 내용).
|
||||
*
|
||||
* [업데이트] = 단가를 지금 M01 값으로 새로 굳힘 · 서버가 낡았다고 보면(`payload.stale` 까닭 한 줄)
|
||||
* 단추에 점 배지(`is-pending`) · 굳힌 시각 · 까닭 = 공용 호버
|
||||
* 연결 점검 = 「연결 없음」 줄 목록(누르면 그 탭 그 줄로 · 까닭 = 공용 호버) + 서버 알림 · M01 에 없는 키
|
||||
* ========================================================================== */
|
||||
|
||||
import { attachHoverInfo } from "@ui/ui_template_hover_info";
|
||||
import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
import { tabName, type Link, type TabId } from "./B09_Estimation_UI_Tabs";
|
||||
|
||||
export interface CheckItem {
|
||||
link: Link;
|
||||
tab: string;
|
||||
name: string;
|
||||
why: string;
|
||||
}
|
||||
|
||||
/** 빠진 연결 줄 · 알림 글(서버 알림 + M01 에 없는 키) */
|
||||
export function checkItems(sheets: Row, payload: Row): { items: CheckItem[]; notes: string[] } {
|
||||
const gaps: Row[] = sheets.gaps || [];
|
||||
const items = gaps.map((g) => ({
|
||||
link: { tab: g.tab as TabId, key: g.key },
|
||||
tab: tabName(g.tab),
|
||||
name: String(g.name ?? ""),
|
||||
why: String(g.why ?? ""),
|
||||
}));
|
||||
const notes: string[] = [...(payload.notes || [])];
|
||||
const missing: string[] = payload.inputs?.missing || [];
|
||||
if (missing.length) notes.push(`M01 에 없는 키 ${missing.length} — ${missing.join(" · ")}`);
|
||||
return { items, notes };
|
||||
}
|
||||
|
||||
/** [업데이트] 호버 글 — 굳힌 시각 + 낡은 까닭 */
|
||||
export function refreshTip(payload: Row | null): string {
|
||||
const made = payload?.book?.made;
|
||||
const when = made ? `단가 굳힘 ${String(made).replace("T", " ")}` : "단가 아직 안 굳힘";
|
||||
const stale = payload?.stale;
|
||||
return `${when} · 누르면 지금 M01 값으로 새로 굳힘${stale ? ` · 업데이트 필요 — ${stale}` : ""}`;
|
||||
}
|
||||
|
||||
export interface CheckPanel {
|
||||
/** 묶음 안 연결 점검 자리 */
|
||||
list: HTMLElement;
|
||||
draw: (payload: Row, sheets: Row) => void;
|
||||
}
|
||||
|
||||
export function createCheckPanel(refresh: HTMLElement, goto: (link: Link) => void): CheckPanel {
|
||||
let payload: Row | null = null;
|
||||
let items: CheckItem[] = [];
|
||||
attachHoverInfo(refresh, () => refreshTip(payload));
|
||||
const list = document.createElement("div");
|
||||
list.className = "b09-check";
|
||||
attachHoverInfo(list, (row) => items[Number(row.dataset.i)]?.why, "[data-i]");
|
||||
|
||||
const draw = (next: Row, sheets: Row): void => {
|
||||
payload = next;
|
||||
refresh.classList.toggle("is-pending", Boolean(next.stale));
|
||||
const made = checkItems(sheets, next);
|
||||
items = made.items;
|
||||
const head = document.createElement("p");
|
||||
head.className = "b09-check__head";
|
||||
head.textContent = `연결 없음 ${items.length}곳`;
|
||||
const rows = document.createElement("div");
|
||||
rows.className = "ui-sidebar-list";
|
||||
rows.append(
|
||||
...items.map((item, i) => {
|
||||
const row = document.createElement("button");
|
||||
row.type = "button";
|
||||
row.className = "ui-sidebar-row";
|
||||
row.dataset.i = String(i);
|
||||
const name = document.createElement("span");
|
||||
name.textContent = item.name;
|
||||
const tab = document.createElement("span");
|
||||
tab.className = "b09-check__tab";
|
||||
tab.textContent = item.tab;
|
||||
row.append(name, tab);
|
||||
row.addEventListener("click", () => goto(item.link));
|
||||
return row;
|
||||
}),
|
||||
);
|
||||
const notes = made.notes.map((text) => {
|
||||
const p = document.createElement("p");
|
||||
p.className = "b09-check__note";
|
||||
p.textContent = text;
|
||||
return p;
|
||||
});
|
||||
list.replaceChildren(head, ...(items.length ? [rows] : []), ...notes);
|
||||
};
|
||||
return { list, draw };
|
||||
}
|
||||
@@ -66,44 +66,33 @@
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
/* 182 설계내역서 레벨 — 글자 크기(줄 높이 28px 고정 → 16px 까지) · 첫 칸 들여쓰기 1em × (레벨 − 1) */
|
||||
.b09-estimation .ui-data-table__table tbody tr.is-lv1 td {
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.b09-estimation .ui-data-table__table tbody tr:is(.is-lv2, .is-lv3) td {
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.b09-estimation .ui-data-table__table tbody tr:is(.is-lv4, .is-lv5, .is-lv6) td {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 182 설계내역서 묶음 줄 굵게 · 레벨 들여쓰기 = 공용 표 level 옵션(201-1) */
|
||||
.b09-estimation .ui-data-table__table tbody tr.is-group td {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.b09-estimation .ui-data-table__table tbody tr.is-lv2 td:first-child {
|
||||
padding-left: calc(var(--spacing-8) + 1em);
|
||||
}
|
||||
|
||||
.b09-estimation .ui-data-table__table tbody tr.is-lv3 td:first-child {
|
||||
padding-left: calc(var(--spacing-8) + 2em);
|
||||
}
|
||||
|
||||
.b09-estimation .ui-data-table__table tbody tr.is-lv4 td:first-child {
|
||||
padding-left: calc(var(--spacing-8) + 3em);
|
||||
}
|
||||
|
||||
.b09-estimation .ui-data-table__table tbody tr:is(.is-lv5, .is-lv6) td:first-child {
|
||||
padding-left: calc(var(--spacing-8) + 4em);
|
||||
}
|
||||
|
||||
/* 166-2 설계내역서 손 줄 — 사용자가 더한 줄 표시(왼쪽 띠) */
|
||||
.b09-estimation .ui-data-table__table tbody tr.is-hand td:first-child {
|
||||
background-image: linear-gradient(to right, var(--color-primary) 3px, transparent 3px);
|
||||
}
|
||||
|
||||
/* 200-2 설계내역서 글 칸 왼쪽 맞춤(수량만 오른쪽) */
|
||||
.b09-estimation [data-b09-table="bill"] .ui-data-table__input:not([data-k="qty"]) {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* 200-2 레벨 도구 줄 — 단추 넷 + 안내 한 줄 */
|
||||
.b09-estimation__level {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b09-estimation__level .b09-estimation__hint {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 181-2 표 보기 — 목록표에서 고른 호표 줄 구간 전체 붉은 외곽선(머리 accent 밑줄은 덮음) */
|
||||
.b09-estimation .ui-data-table__table tbody tr.is-pick td {
|
||||
--pick-t: inset 0 0 0 0 transparent;
|
||||
@@ -135,20 +124,51 @@
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
/* 185-1 총괄 · 원가계산서 레벨 — 글자 크기 = 위 is-lvN · 들여쓰기 = 명칭 칸(is-indent) · 앞 칸(is-flat)은 그대로 */
|
||||
.b09-estimation .ui-data-table__table tbody tr[class*="is-lv"] td.is-flat:first-child {
|
||||
padding-left: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b09-estimation .ui-data-table__table tbody tr.is-lv2 td.is-indent {
|
||||
padding-left: calc(var(--spacing-8) + 1em);
|
||||
}
|
||||
|
||||
.b09-estimation .ui-data-table__table tbody tr.is-lv3 td.is-indent {
|
||||
padding-left: calc(var(--spacing-8) + 2em);
|
||||
}
|
||||
|
||||
/* 184-3 설계내역서 고친 칸 — 자동 값과 다른 칸(풍선 = 자동 값) */
|
||||
.b09-estimation .ui-data-table__table tbody td.is-edited {
|
||||
background: color-mix(in srgb, var(--color-warning) 22%, var(--color-canvas));
|
||||
}
|
||||
|
||||
/* 203-1 좌측 「데이터 점검」 — [업데이트] 필요 = 단추 위 점 배지(B05 [3D 업데이트] 꼴) */
|
||||
.b09-options .ui-btn.is-pending {
|
||||
position: relative;
|
||||
border-color: var(--color-warning);
|
||||
}
|
||||
|
||||
.b09-options .ui-btn.is-pending::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-warning);
|
||||
}
|
||||
|
||||
/* 연결 점검 목록 — 이름 · 탭(작게) · 알림 글 */
|
||||
.b09-check__head,
|
||||
.b09-check__note {
|
||||
margin: var(--spacing-8) 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b09-check__tab {
|
||||
flex: none;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
/* 줄 끌기(204-1) — 놓을 자리 · 레벨 미리 보기 선 · 못 놓는 자리 = 빨간 점선 */
|
||||
.b09-estimation__drop {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
height: 0;
|
||||
border-top: 2px solid var(--color-primary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.b09-estimation__drop.is-bad {
|
||||
border-top: 2px dashed var(--color-danger);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* 원본 = 기번3 공통 원가산출 엑셀 · 탭 하나 = 화면 표 하나(`…_UI_Tabs` 차례) · 옛 원가 코드 안 봄
|
||||
* 자료 = GET …/estimation(입력 · 사용자 값 · 서버가 셈한 표) · 조작 = 캐시(`b09-estimation`) +
|
||||
* 짝 계산(`…_Calc_*.ts`)으로 바로 다시 셈 · [저장] = PUT → 서버 정본 · [단가 갱신] = POST …/prices
|
||||
* 짝 계산(`…_Calc_*.ts`)으로 바로 다시 셈 · [저장] = PUT → 서버 정본 · [업데이트] = POST …/prices
|
||||
* 호표 칸 누름 = 그 탭 그 줄로 이동 · 자동 저장 없음
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import { createDataTable, type DataTable } from "@ui/ui_template_data_table";
|
||||
import { cellColumn, createDataTable, type DataTable } from "@ui/ui_template_data_table";
|
||||
import { attachHoverInfo } from "@ui/ui_template_hover_info";
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
|
||||
@@ -31,9 +31,9 @@ import { computeEstimation } from "./B09_Estimation_Calc_Bill";
|
||||
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 {
|
||||
buildCheck,
|
||||
rowIndex,
|
||||
TABS,
|
||||
type Link,
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
} 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 { levelBar, pickedClass } from "./B09_Estimation_UI_Tabs_Bill_Level";
|
||||
import {
|
||||
buildSumExpense,
|
||||
buildSumLabor,
|
||||
@@ -69,7 +70,6 @@ function L(key: keyof typeof ui_locales): string {
|
||||
const LATER: Record<string, string> = {};
|
||||
|
||||
const BUILDERS: Partial<Record<TabId, TabBuild>> = {
|
||||
check: buildCheck,
|
||||
dg_list: buildDaegaList,
|
||||
dg: buildDaegaBlocks,
|
||||
bill: buildBill,
|
||||
@@ -151,7 +151,7 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
let payload: Row | null = null;
|
||||
let user: Row = {};
|
||||
let sheets: Row = {};
|
||||
let current: TabId = "check";
|
||||
let current: TabId = TABS[0][0];
|
||||
|
||||
// 181-1 위 탭(md) · 아래 탭(sm) 두 줄 — 줄 그리기 = `…_UI_TabBar`
|
||||
const bars = createTabBars((tab) => show(tab));
|
||||
@@ -162,11 +162,10 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
body.className = "b09-estimation";
|
||||
body.append(bars.top, bars.sub, panel);
|
||||
|
||||
const made = document.createElement("p");
|
||||
made.className = "b09-estimation__hint";
|
||||
|
||||
const recompute = (): void => {
|
||||
if (payload) sheets = computeSheets(payload, user);
|
||||
if (!payload) return;
|
||||
sheets = computeSheets(payload, user);
|
||||
check.draw(payload, sheets); // 203-1 좌측 연결 점검 · [업데이트] 배지
|
||||
};
|
||||
|
||||
const setUser = (next: Row): void => {
|
||||
@@ -210,6 +209,8 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
? (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));
|
||||
@@ -223,7 +224,7 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
if (spec.choices)
|
||||
made.root.addEventListener("click", (event) => {
|
||||
const cell = (event.target as Element).closest<HTMLTableCellElement>("tr[data-i] td");
|
||||
const column = cell ? spec.columns[cell.cellIndex] : undefined;
|
||||
const column = cell ? spec.columns[cellColumn(cell)] : undefined;
|
||||
const items =
|
||||
cell && column && spec.choices?.(Number(cell.parentElement?.dataset.i), column.key);
|
||||
if (!cell || !items?.length) return;
|
||||
@@ -235,7 +236,7 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
attachHoverInfo(
|
||||
made.root,
|
||||
(cell) => {
|
||||
const column = spec.columns[(cell as HTMLTableCellElement).cellIndex];
|
||||
const column = spec.columns[cellColumn(cell as HTMLTableCellElement)];
|
||||
return column && spec.tip?.(Number(cell.parentElement?.dataset.i), column.key);
|
||||
},
|
||||
"tr[data-i] td",
|
||||
@@ -259,8 +260,11 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
}),
|
||||
);
|
||||
}
|
||||
const table = makeTable();
|
||||
panel.replaceChildren(table.root, ...(hint ? [hint] : [])); // 202-1 안내 줄 = 표 아래
|
||||
// 200-2 설계내역서 = 표 위 레벨 도구 줄 · 고른 줄 강조 · 202-1 안내 줄 = 표 아래
|
||||
const bill = (spec as BillTable).menu ? (spec as BillTable) : null;
|
||||
const table = makeTable(bill ? (i) => pickedClass(bill.rows[i]) : undefined);
|
||||
const level = bill ? levelBar(table.root, bill) : null;
|
||||
panel.replaceChildren(...(level ? [level] : []), table.root, ...(hint ? [hint] : []));
|
||||
if (focus !== undefined) {
|
||||
const index = rowIndex(spec.rows, focus);
|
||||
if (index >= 0) requestAnimationFrame(() => table.focusRow(index));
|
||||
@@ -272,9 +276,6 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
const draft = projectId && !fromServer ? readState<Row>("b09-estimation", projectId) : null;
|
||||
user = draft ?? next.user ?? {};
|
||||
recompute();
|
||||
made.textContent = next.book?.made
|
||||
? `단가 굳힘 ${String(next.book.made).replace("T", " ")} — [단가 갱신] 때만 새로`
|
||||
: "단가 아직 안 굳힘 — [저장] 이나 [단가 갱신] 때 M01 값을 굳힘";
|
||||
show(current);
|
||||
};
|
||||
|
||||
@@ -303,12 +304,13 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
});
|
||||
save.dataset.b09Save = "";
|
||||
const refresh = createButton({
|
||||
label: "단가 갱신",
|
||||
label: "업데이트",
|
||||
variant: "ghost",
|
||||
onClick: () =>
|
||||
void run("/estimation/prices", "POST", "단가를 지금 M01 값으로 새로 굳혔습니다."),
|
||||
});
|
||||
refresh.dataset.b09Refresh = "";
|
||||
const check = createCheckPanel(refresh, (link) => show(link.tab, link.key));
|
||||
// 184-3 내역 초기화 — 설계내역서 덮개(user.bill) 지우고 바로 저장 → 기본 로직 내역
|
||||
const reset = createButton({
|
||||
label: "내역 초기화",
|
||||
@@ -328,8 +330,8 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
section.className = "ui-collapsible ui-sidebar-section ui-sidebar-card";
|
||||
const heading = document.createElement("h3");
|
||||
heading.className = "ui-collapsible__title";
|
||||
heading.textContent = "원가 단가";
|
||||
section.append(heading, made, refresh);
|
||||
heading.textContent = "데이터 점검";
|
||||
section.append(heading, refresh, check.list);
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "ui-sidebar-actions";
|
||||
actions.append(save, reset);
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
* B09_Estimation_UI_Tabs.ts
|
||||
* B09 탭 목록 · 표 짜기 공용(PLAN 144-1) — 탭 하나 = 화면 표 하나(실무 엑셀 탭 차례).
|
||||
*
|
||||
* 탭 = 연결 점검(목차 · 안내) · 원가 · 총괄 · 내역 · 대가 둘 · 산근 둘 · 기초 · 중기 둘 · 단가 넷 · 집계표 넷
|
||||
* 탭 = 원가 · 총괄 · 내역 · 대가 둘 · 산근 둘 · 기초 · 중기 둘 · 단가 넷 · 집계표 넷
|
||||
* 표 = 공용 `createDataTable` · 「연결 없음」 칸 = 빈칸 · 호표 칸 누름 = 그 탭 그 줄로 이동(`link`)
|
||||
* 탭별 표 = `…_UI_Tabs_Base`(144) · `…_UI_Tabs_Cost`(145) · 여기는 차례 · 꼴 · 연결 점검만
|
||||
* 탭별 표 = `…_UI_Tabs_Base`(144) · `…_UI_Tabs_Cost`(145) · 여기는 차례 · 꼴만
|
||||
* 연결 점검(옛 첫 탭) = 좌측 「데이터 점검」 묶음(203-1 `…_UI_Check`)
|
||||
* ========================================================================== */
|
||||
|
||||
import type { MapContextMenuItem } from "@ui/ui_template_context_menu";
|
||||
import type { DataColumn, DataRow } from "@ui/ui_template_data_table";
|
||||
import type { DataColumn, DataRow, DataTableOptions } from "@ui/ui_template_data_table";
|
||||
import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
|
||||
export type TabId =
|
||||
| "check"
|
||||
| "cost"
|
||||
| "total"
|
||||
| "bill"
|
||||
@@ -34,7 +34,6 @@ export type TabId =
|
||||
|
||||
/** 엑셀 탭 차례 — [id, 탭 이름] */
|
||||
export const TABS: Array<[TabId, string]> = [
|
||||
["check", "연결 점검"],
|
||||
["cost", "공사원가계산서"],
|
||||
["total", "총괄설계내역서"],
|
||||
["bill", "설계내역서"],
|
||||
@@ -59,7 +58,6 @@ export const tabName = (id: string): string => TABS.find(([t]) => t === id)?.[1]
|
||||
|
||||
/** 위 탭(181-1) — [위 탭 이름, 아래 탭들] · 아래 탭 하나뿐 = 위 탭 단독 · 첫 아래 탭 = 위 탭 키 */
|
||||
export const TAB_GROUPS: Array<[string, TabId[]]> = [
|
||||
["연결 점검", ["check"]],
|
||||
["공사원가계산서", ["cost"]],
|
||||
["총괄설계내역서", ["total"]],
|
||||
["설계내역서", ["bill"]],
|
||||
@@ -112,10 +110,20 @@ export interface TabTable {
|
||||
cellClass?: (index: number, key: string) => string | null | undefined;
|
||||
/** 칸 누름 고르기 메뉴(183-3 · 칸 아래 공용 메뉴 · 빈 것 = 안 엶) */
|
||||
choices?: (index: number, key: string) => MapContextMenuItem[] | null;
|
||||
/** 레벨(201-1 · 공용 표 level · 보기 전용) — 그 열 들여쓰기 */
|
||||
level?: DataTableOptions["level"];
|
||||
/** 같은 값 세로 병합할 열(201-1 · 공용 표 mergeSame) */
|
||||
mergeSame?: string[];
|
||||
}
|
||||
|
||||
export type TabBuild = (ctx: TabContext) => TabTable | string;
|
||||
|
||||
/** 201-1 호표 묶음 레벨(보기 전용) — 머리 · 합계(제목 줄) = 1 · 그 밖 = 2 · key 열을 들여씀 */
|
||||
export const blockLevel = (key: string, top: (index: number) => boolean): TabTable["level"] => ({
|
||||
key,
|
||||
of: (i) => (top(i) ? 1 : 2),
|
||||
});
|
||||
|
||||
/* ── 열 꼴 ────────────────────────────────────────────────────────────── */
|
||||
|
||||
export const text = (key: string, label: string, width?: number, group?: string[]): DataColumn => ({
|
||||
@@ -156,36 +164,3 @@ export function parseNumber(value: string): number | null | undefined {
|
||||
export function rowIndex(rows: DataRow[], key: string): number {
|
||||
return rows.findIndex((row) => row.key === key);
|
||||
}
|
||||
|
||||
/* ── 연결 점검(목차 · 안내) ───────────────────────────────────────────── */
|
||||
|
||||
export const buildCheck: TabBuild = ({ sheets, payload }): TabTable => {
|
||||
const gaps: Row[] = sheets.gaps || [];
|
||||
const rows: DataRow[] = gaps.map((g, i) => ({
|
||||
no: i + 1,
|
||||
key: `${g.tab}|${g.key}|${i}`,
|
||||
tab: tabName(g.tab),
|
||||
name: g.name,
|
||||
why: g.why,
|
||||
}));
|
||||
const notes: string[] = [...(payload.notes || [])];
|
||||
const missing: string[] = payload.inputs?.missing || [];
|
||||
if (missing.length) notes.push(`M01 에 없는 키 ${missing.length} — ${missing.join(" · ")}`);
|
||||
return {
|
||||
columns: [
|
||||
qty("no", "번호", 0),
|
||||
text("tab", "탭", 150),
|
||||
text("name", "명칭", 260),
|
||||
text("why", "까닭", 320),
|
||||
],
|
||||
rows,
|
||||
frozen: 1,
|
||||
hint:
|
||||
`연결 없음 ${gaps.length}곳 — 줄을 누르면 그 탭 그 줄로 · 빈칸 = 값을 낸 원천이 없음(0 을 채우지 않음)` +
|
||||
(notes.length ? ` · ${notes.join(" · ")}` : ""),
|
||||
link: (i) => {
|
||||
const g = gaps[i];
|
||||
return g ? { tab: g.tab as TabId, key: g.key } : null;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { DataRow } from "@ui/ui_template_data_table";
|
||||
import { SOURCES, type Row } from "./B09_Estimation_Calc_Base";
|
||||
import { openQuotes } from "./B09_Estimation_UI_Tabs_Base_Quote";
|
||||
import {
|
||||
blockLevel,
|
||||
money,
|
||||
parseNumber,
|
||||
qty,
|
||||
@@ -51,14 +52,14 @@ export const buildLabor: TabBuild = ({ sheets, payload }) =>
|
||||
priceList(
|
||||
sheets.labor || [],
|
||||
"단가(원/인)",
|
||||
`노임 = 굳힌 단가(${madeText(payload)}) · M01 인력 · 바꾸려면 [단가 갱신]`,
|
||||
`노임 = 굳힌 단가(${madeText(payload)}) · M01 인력 · 바꾸려면 [업데이트]`,
|
||||
);
|
||||
|
||||
export const buildExpense: TabBuild = ({ sheets, payload }) =>
|
||||
priceList(
|
||||
sheets.expense || [],
|
||||
"기준가(천원)",
|
||||
`기계 손료 기준가 = 굳힌 단가(${madeText(payload)}) · M01 기계 가격 · 바꾸려면 [단가 갱신]`,
|
||||
`기계 손료 기준가 = 굳힌 단가(${madeText(payload)}) · M01 기계 가격 · 바꾸려면 [업데이트]`,
|
||||
);
|
||||
|
||||
function madeText(payload: Row): string {
|
||||
@@ -102,6 +103,7 @@ export const buildBasis: TabBuild = ({ sheets }): TabTable => {
|
||||
],
|
||||
rows,
|
||||
frozen: 2,
|
||||
mergeSame: ["part"],
|
||||
hint: "운전사 시간 노임 = ROUNDDOWN(일당 × 1/8 × 16/12 × 25/20, 0)(M01 CC000336 · 건협기술-2230) · 환율 = M01 한국은행 · 인건비 줄 누름 = 노무비목록표",
|
||||
link: (i) => {
|
||||
const row = rows[i];
|
||||
@@ -344,7 +346,7 @@ export const buildMachineBlocks: TabBuild = ({ sheets }): TabTable => {
|
||||
const amt = (part: string): number | null => (ln.kind === part ? ln.amount : null);
|
||||
rows.push({
|
||||
key: `${b.key}|${ln.name}`,
|
||||
name: ` ${ln.name}`,
|
||||
name: ln.name,
|
||||
spec: ln.spec,
|
||||
qty: ln.qty,
|
||||
unit: ln.unit,
|
||||
@@ -361,7 +363,7 @@ export const buildMachineBlocks: TabBuild = ({ sheets }): TabTable => {
|
||||
}
|
||||
rows.push({
|
||||
key: `${b.key}|합계`,
|
||||
name: " 합 계",
|
||||
name: "합 계",
|
||||
sum: b.sum,
|
||||
labor: b.labor,
|
||||
material: b.material,
|
||||
@@ -388,5 +390,6 @@ export const buildMachineBlocks: TabBuild = ({ sheets }): TabTable => {
|
||||
frozen: 1,
|
||||
hint: "손료 = 기준가(천원) × 손료계수 ÷ 10,000 · 운전사 = 인 × 시간 노임 · 연료 = ℓ/hr × 적용 단가 · 잡품 = 연료 금액 × % · 줄 ROUNDDOWN 1 · 비목 합 ROUNDDOWN 0 · 줄 누름 = 단가를 낸 탭",
|
||||
link: (i) => links[i] ?? null,
|
||||
level: blockLevel("name", (i) => !String(rows[i]?.key).includes("|")),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* 고칠 칸 = 항목 줄 명칭 · 규격 · 단위 · 수량 · 묶음 줄 이름 · 조달수수료 율(%) — 자동 줄은 덮개
|
||||
* 고친 칸 = 칸 강조(셈 줄 `auto_{칸}` 있음) · 풍선 = 자동 값
|
||||
* 줄 누름 = 단가를 낸 탭 줄(자재 → 재료비목록표 · 구조물 → 일위대가목록표)
|
||||
* 레벨 = 줄 `level`(셈이 냄) → class is-lvN · 글자 크기 · 첫 칸 들여쓰기(CSS)
|
||||
* 레벨 = 줄 `level`(셈이 냄) → 공용 표 level 옵션(공종 · 명칭 칸 들여쓰기 · 201-1) · 다루기 = `…_Bill_Level`
|
||||
* 「연결 ▾」 칸 = 항목 줄(자재 · 조달수수료 빼고) · 누름 = 우클릭 [연결] 과 같은 메뉴
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
import { money, parseNumber, qty, text, type TabBuild, type TabId } from "./B09_Estimation_UI_Tabs";
|
||||
import {
|
||||
billMenu,
|
||||
dropMove,
|
||||
editCell,
|
||||
linkable,
|
||||
openLinkMenu,
|
||||
@@ -93,9 +94,11 @@ export const buildBill: TabBuild = (ctx): BillTable => {
|
||||
const r = rows[i];
|
||||
if (!r) return null;
|
||||
const title = r.kind === "title" ? "is-title" : r.kind === "group" ? "is-group" : "";
|
||||
return [`is-lv${r.level ?? 1}`, title, r.auto ? "" : "is-hand"].filter((c) => c).join(" ");
|
||||
return [title, r.auto ? "" : "is-hand"].filter((c) => c).join(" ") || null;
|
||||
},
|
||||
level: { key: "name", of: (i) => rows[i]?.level as number | undefined },
|
||||
menu: (i) => billMenu(ctx, raw, i),
|
||||
drop: (i, parent, before) => dropMove(ctx, raw, i, parent, before),
|
||||
onCell: (i, key) => {
|
||||
if (key === "connect") openLinkMenu(ctx, raw, i);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Tabs_Bill_Drag.ts
|
||||
* B09 설계내역서 줄 끌기(PLAN 204-1) — 줄을 잡아 위아래 = 자리 · 좌우 = 레벨(한 칸 = 1em) · 공용 표 위 덧그림.
|
||||
*
|
||||
* 놓을 자리 = 줄 사이 틈(포인터가 줄 가운데 위 = 그 줄 앞) · 끄는 줄 자손 = 함께 감 · 자기 자손 안 = 못 놓음
|
||||
* 레벨 = 위 줄 + 1(위 줄 아래로 · 항목이면 저절로 묶음) ~ 아래 줄 레벨 · 맨 위(1) = 맨 위 줄만
|
||||
* 놓기 = 표 drop(`…_Bill_Edit` dropMove — 메뉴와 같은 지킬 것) · 미리 보기 선(못 놓음 = is-bad · 커서 not-allowed)
|
||||
* 잡기 = 글 칸 · 단추 밖 아무 칸 · 6px 넘게 움직여야 끌기(그냥 누름 = 줄 고름)
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DataRow } from "@ui/ui_template_data_table";
|
||||
import type { BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit";
|
||||
|
||||
export interface DropAt {
|
||||
parent: string | null;
|
||||
before: string | null;
|
||||
level: number;
|
||||
}
|
||||
|
||||
const holds = (row: DataRow): boolean => row.key !== "fee" && !String(row.key).startsWith("mat:");
|
||||
|
||||
/** 틈 gap(0 ~ 줄 수 · 그 줄 앞) · 바라는 레벨 → 놓을 자리 · 같은 자리 · 못 놓음 = null */
|
||||
export function dropAt(rows: DataRow[], from: number, gap: number, want: number): DropAt | null {
|
||||
const row = rows[from];
|
||||
if (!row) return null;
|
||||
const lv = (r: DataRow | undefined): number => Number(r?.level ?? 1);
|
||||
let end = from + 1;
|
||||
while (end < rows.length && lv(rows[end]) > lv(row)) end += 1;
|
||||
if (gap > from && gap < end) return null; // 자기 자손 안
|
||||
const rest = [...rows.slice(0, from), ...rows.slice(end)];
|
||||
const g = gap <= from ? gap : gap - (end - from);
|
||||
const up = rest[g - 1];
|
||||
const down = rest[g];
|
||||
const max = up ? lv(up) + (holds(up) ? 1 : 0) : 1;
|
||||
const min = Math.max(down ? lv(down) : 1, lv(row) === 1 ? 1 : 2);
|
||||
if (min > max) return null;
|
||||
const level = Math.min(max, Math.max(min, want));
|
||||
if (level === lv(row) && (gap === from || gap === end)) return null; // 제자리
|
||||
let parent: DataRow | undefined = up;
|
||||
const byKey = new Map(rest.map((r) => [String(r.key), r]));
|
||||
while (parent && lv(parent) >= level) parent = byKey.get(String(parent.parent ?? ""));
|
||||
return {
|
||||
parent: parent ? String(parent.key) : null,
|
||||
before: down && lv(down) === level ? String(down.key) : null,
|
||||
level,
|
||||
};
|
||||
}
|
||||
|
||||
/** 표 root 에 줄 끌기를 붙임 — 놓으면 done(끈 줄 키) 뒤 drop 실행 */
|
||||
export function attachDrag(root: HTMLElement, table: BillTable, done: (key: string) => void): void {
|
||||
let endAt = 0;
|
||||
// 끌고 난 누름 = 줄 고름 아님
|
||||
root.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
if (Date.now() - endAt < 300) event.stopPropagation();
|
||||
},
|
||||
true,
|
||||
);
|
||||
root.addEventListener("pointerdown", (down) => {
|
||||
const target = down.target as Element;
|
||||
if (down.button !== 0 || !table.drop || target.closest("input,textarea,select,button,a"))
|
||||
return;
|
||||
const tr = target.closest<HTMLElement>("tr[data-i]");
|
||||
if (!tr || !root.contains(tr)) return;
|
||||
const from = Number(tr.dataset.i);
|
||||
const step = parseFloat(getComputedStyle(tr).fontSize) || 14;
|
||||
const line = document.createElement("div");
|
||||
line.className = "b09-estimation__drop";
|
||||
let on = false;
|
||||
let run: (() => void) | null = null;
|
||||
let seen = "";
|
||||
const move = (event: PointerEvent): void => {
|
||||
const dx = event.clientX - down.clientX;
|
||||
if (!on && Math.abs(dx) + Math.abs(event.clientY - down.clientY) < 6) return;
|
||||
if (!on) {
|
||||
on = true;
|
||||
document.body.append(line);
|
||||
document.body.style.userSelect = "none";
|
||||
window.getSelection()?.removeAllRanges();
|
||||
}
|
||||
const trs = table.rows.map((_r, i) => root.querySelector<HTMLElement>(`tr[data-i="${i}"]`));
|
||||
let gap = trs.length;
|
||||
let y = trs.at(-1)?.getBoundingClientRect().bottom ?? 0;
|
||||
for (const [i, el] of trs.entries()) {
|
||||
const box = el?.getBoundingClientRect();
|
||||
if (box && event.clientY < box.top + box.height / 2) {
|
||||
[gap, y] = [i, box.top];
|
||||
break;
|
||||
}
|
||||
}
|
||||
const want = Number(table.rows[from]?.level ?? 1) + Math.round(dx / step);
|
||||
const at = dropAt(table.rows, from, gap, want);
|
||||
const key = `${gap}|${at?.level}`;
|
||||
if (key !== seen) {
|
||||
seen = key;
|
||||
run = at ? table.drop!(from, at.parent, at.before) : null;
|
||||
}
|
||||
const box = root.getBoundingClientRect();
|
||||
const left = box.left + ((at?.level ?? want) - 1) * step + 8;
|
||||
Object.assign(line.style, {
|
||||
top: `${y - 1}px`,
|
||||
left: `${left}px`,
|
||||
width: `${Math.max(box.right - left, 40)}px`,
|
||||
});
|
||||
line.classList.toggle("is-bad", !run);
|
||||
document.body.style.cursor = run ? "grabbing" : "not-allowed";
|
||||
};
|
||||
const stop = (event: PointerEvent): void => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", stop);
|
||||
window.removeEventListener("pointercancel", stop);
|
||||
line.remove();
|
||||
document.body.style.userSelect = "";
|
||||
document.body.style.cursor = "";
|
||||
if (!on) return;
|
||||
endAt = Date.now();
|
||||
if (event.type === "pointerup" && run) {
|
||||
done(String(table.rows[from].key));
|
||||
run();
|
||||
}
|
||||
};
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", stop);
|
||||
window.addEventListener("pointercancel", stop);
|
||||
});
|
||||
}
|
||||
@@ -11,6 +11,8 @@
|
||||
* 막음 = 조달수수료 · 관급자재대 제목 옮기기 · 지우기 · 자재(mat:) 줄 맨 위 묶음 돈 갈래(관급 · 직접비)
|
||||
* 바뀌는 옮김 · 자재 줄 지우기(주인 = 대비표 표시) · 깊이 5 넘음 · 자식 있는 맨 위 묶음 지우기
|
||||
* 연결 고르기 창 = `…_UI_Tabs_Bill_Link` · 우클릭 = 공용 메뉴(`ui_template_context_menu` · 화면 고정 자리)
|
||||
* 묶음 자동(204) = 줄 아래로 들임 · 끌어 놓음 → 그 줄 저절로 묶음(셈) · 자식이 다 빠진 새 묶음 = 항목(kind 지움 ·
|
||||
* 맨 위 공종은 그대로) · 줄을 옮기면 자손이 함께 감(부모 키 그대로) · 끌어 놓기 = `dropMove`(`…_Bill_Drag`)
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DataRow } from "@ui/ui_template_data_table";
|
||||
@@ -24,7 +26,11 @@ import type { TabContext, TabTable } from "./B09_Estimation_UI_Tabs";
|
||||
import { linkItems, type ApplyRef } from "./B09_Estimation_UI_Tabs_Bill_Link";
|
||||
|
||||
/** 설계내역서 표 = 탭 표 + 줄 우클릭 메뉴 */
|
||||
export type BillTable = TabTable & { menu?: (index: number) => MapContextMenuItem[] };
|
||||
export type BillTable = TabTable & {
|
||||
menu?: (index: number) => MapContextMenuItem[];
|
||||
/** 끌어 놓기(204) — 줄 index 를 parent(null = 맨 위) 아래 before 앞(null = 끝)으로 · 못 하면 null */
|
||||
drop?: (index: number, parent: string | null, before: string | null) => (() => void) | null;
|
||||
};
|
||||
|
||||
const NEW_NAME = "새 줄";
|
||||
const NEW_SECTION = "새 공종";
|
||||
@@ -47,6 +53,8 @@ interface Work {
|
||||
|
||||
/** 옮기기 · 지우기 · 연결 없는 줄 — 조달수수료 · 관급자재대 제목 */
|
||||
const fixed = (key: string): boolean => key === "fee" || key === "sec:G";
|
||||
/** 자식을 둘 수 있는 줄 — 자재 · 조달수수료 빼고(항목은 저절로 묶음) */
|
||||
const canHold = (key: string): boolean => key !== "fee" && !key.startsWith("mat:");
|
||||
|
||||
/** 연결 ▾ 있는 줄 — 항목 줄(자재 = 대비표 단가 · 조달수수료 = 율 빼고) */
|
||||
export const linkable = (row: DataRow | undefined): boolean =>
|
||||
@@ -148,6 +156,11 @@ function attempt(
|
||||
const before = w.bill.rows.map((e) => ({ ...e }));
|
||||
if (change(w) === false) return null;
|
||||
w.bill.rows = tidy(w.bill.rows);
|
||||
// 자식이 다 빠진 새 묶음 = 항목(맨 위 공종 빼고) — 자식이 생긴 항목 = 셈이 묶음으로
|
||||
const had = new Set(before.map((e) => e.parent));
|
||||
const has = new Set(w.bill.rows.map((e) => e.parent));
|
||||
for (const e of w.bill.rows)
|
||||
if (e.kind === "group" && e.parent && had.has(e.key) && !has.has(e.key)) delete e.kind;
|
||||
return fits(w, before) ? () => put(ctx, w.bill) : null;
|
||||
}
|
||||
|
||||
@@ -273,7 +286,7 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex
|
||||
add(2, "들여쓰기 →", (w, e) => {
|
||||
const sib = siblings(w, e.parent);
|
||||
const up = sib[sib.indexOf(e) - 1];
|
||||
if (!up || !isGroup(w, up.key)) return false;
|
||||
if (!up || !canHold(up.key)) return false;
|
||||
e.parent = up.key;
|
||||
w.bill.rows.push(pull(w.bill.rows, key));
|
||||
});
|
||||
@@ -315,6 +328,28 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 끌어 놓기(204) — 자손째 parent 아래 before 앞으로 · 지킬 것(깊이 · 자재 돈 갈래)은 메뉴와 같음 */
|
||||
export function dropMove(
|
||||
ctx: TabContext,
|
||||
rows: DataRow[],
|
||||
i: number,
|
||||
parent: string | null,
|
||||
before: string | null,
|
||||
): (() => void) | null {
|
||||
const key = String(rows[i]?.key);
|
||||
if (!rows[i] || fixed(key) || (parent !== null && !canHold(parent))) return null;
|
||||
return attempt(ctx, rows, (w) => {
|
||||
const e = entryOf(w, key);
|
||||
if (!e || (parent !== null && !entryOf(w, parent))) return false;
|
||||
if (parent !== null && chain(w.bill.rows, parent).includes(e)) return false; // 자기 자손 아래
|
||||
if (parent === null) delete e.parent;
|
||||
else e.parent = parent;
|
||||
pull(w.bill.rows, key);
|
||||
const at = before === null ? -1 : find(w.bill.rows, before);
|
||||
w.bill.rows.splice(at < 0 ? w.bill.rows.length : at, 0, e);
|
||||
});
|
||||
}
|
||||
|
||||
let shared: MapContextMenu | null = null;
|
||||
/** 마지막으로 누른 칸 — 「연결 ▾」 단추 메뉴 자리 */
|
||||
let lastCell: Element | null = null;
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Tabs_Bill_Level.ts
|
||||
* B09 설계내역서 레벨 도구 줄(PLAN 200-2) — 표 위 [→ 들여쓰기] [← 내어쓰기] [↑ 위로] [↓ 아래로] · 키 · 안내 한 줄.
|
||||
*
|
||||
* 동작 = 줄 우클릭 메뉴(`…_UI_Tabs_Bill_Edit` billMenu)의 같은 항목을 부름 — 메뉴에 없으면 단추 흐림 · 키 안 먹음
|
||||
* 고른 줄 = 표 줄 누름(키로 기억 · 다시 그려도 남음) · 표시 = 줄 class is-focus(Shell 이 줄 class 곁값으로)
|
||||
* 키 = Tab 들여쓰기 · Shift+Tab 내어쓰기 · Alt+↑↓ 옮기기 · Esc 편집 끝(편집 아니면 고름 풂)
|
||||
* 방향키(204) = → 들여쓰기 · ← 내어쓰기 · ↑↓ 옮기기(같은 메뉴 항목) · 끌기 = `…_Bill_Drag`(놓은 줄 = 고른 줄)
|
||||
* 글 칸 편집 중 · 초점이 표 · 도구 줄 밖이면 안 먹음 — 줄 누름이 다른 탭으로 가는 줄은 글 칸을 눌러 고름
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DataRow } from "@ui/ui_template_data_table";
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import { attachHoverInfo } from "@ui/ui_template_hover_info";
|
||||
import { attachDrag } from "./B09_Estimation_UI_Tabs_Bill_Drag";
|
||||
import type { BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit";
|
||||
|
||||
/** [단추 글, 메뉴 글] — 차례 = 들여 · 내어 · 위 · 아래 */
|
||||
const ACTS: [string, string][] = [
|
||||
["→ 들여쓰기", "들여쓰기 →"],
|
||||
["← 내어쓰기", "내어쓰기 ←"],
|
||||
["↑ 위로", "위로 ↑"],
|
||||
["↓ 아래로", "아래로 ↓"],
|
||||
];
|
||||
|
||||
const GUIDE =
|
||||
"레벨 — 줄을 누르고 단추 · 방향키 ←→ 레벨 · ↑↓ 옮김(Tab · Shift+Tab · Alt+↑↓ 도) · 줄 끌기";
|
||||
const GUIDE_MORE =
|
||||
"레벨 = 공종 · 명칭 칸 왼쪽 들여쓰기(레벨마다 한 칸) · 묶음 금액 = 아래 줄 합 · " +
|
||||
"들여쓰기 = 바로 위 줄 아래로(항목이면 저절로 묶음 · 자식이 다 빠진 묶음 = 다시 항목) · " +
|
||||
"내어쓰기 = 부모 묶음 밖으로(맨 위 공종은 [새 공종 추가]) · " +
|
||||
"위로 · 아래로 = 같은 부모 안에서 아래 줄째 자리 바꿈 · 못 하는 동작 = 흐린 단추 · " +
|
||||
"끌기 = 줄을 잡아 위아래 = 자리 · 좌우 = 레벨(아래 줄째 · 빨간 선 = 못 놓음) · " +
|
||||
"키는 글 칸 편집 중이 아닐 때(Esc = 편집 끝 · 한 번 더 Esc = 고름 풂) · " +
|
||||
"묶음 줄로 바꾸기 · 더하기 · 지우기 = 줄 우클릭";
|
||||
|
||||
let picked: string | null = null;
|
||||
/** 지금 그린 도구 줄 — 키가 부름 */
|
||||
let live: {
|
||||
root: HTMLElement;
|
||||
bar: HTMLElement;
|
||||
run: (label: string) => void;
|
||||
sync: () => void;
|
||||
} | null = null;
|
||||
let keysOn = false;
|
||||
|
||||
/** 고른 줄 class — Shell 줄 class 곁값 */
|
||||
export const pickedClass = (row: DataRow | undefined): string | null =>
|
||||
row && picked !== null && String(row.key) === picked ? "is-focus" : null;
|
||||
|
||||
/** 그 줄 메뉴 항목(글 → 동작) — 메뉴에 없는 동작 = 못 함 */
|
||||
export function menuActs(table: BillTable, index: number): Map<string, () => void> {
|
||||
const items = index >= 0 && table.menu ? table.menu(index) : [];
|
||||
return new Map(items.filter((x): x is [string, () => void] => Array.isArray(x)));
|
||||
}
|
||||
|
||||
/** 키 → 메뉴 글 · 편집 중 · 다른 키 = null */
|
||||
export function keyAct(
|
||||
e: { key: string; shiftKey: boolean; altKey: boolean; ctrlKey?: boolean },
|
||||
editing: boolean,
|
||||
): string | null {
|
||||
if (editing || e.ctrlKey) return null;
|
||||
if (e.key === "Tab" && !e.altKey) return ACTS[e.shiftKey ? 1 : 0][1];
|
||||
if (e.shiftKey) return null;
|
||||
const arrow = ["ArrowRight", "ArrowLeft", "ArrowUp", "ArrowDown"].indexOf(e.key);
|
||||
// 방향키(204) — 그냥 · Alt 같음(Alt+←→ = 브라우저 뒤로 · 앞으로라 안 받음)
|
||||
if (arrow < 0 || (e.altKey && arrow < 2)) return null;
|
||||
return ACTS[arrow][1];
|
||||
}
|
||||
|
||||
const editing = (at: Element | null): boolean =>
|
||||
/^(INPUT|TEXTAREA|SELECT)$/.test(at?.tagName ?? "") ||
|
||||
!!(at as HTMLElement | null)?.isContentEditable;
|
||||
|
||||
function listenKeys(): void {
|
||||
if (keysOn) return;
|
||||
keysOn = true;
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (!live?.root.isConnected || picked === null) return;
|
||||
const at = document.activeElement;
|
||||
if (at && at !== document.body && !live.root.contains(at) && !live.bar.contains(at)) return;
|
||||
// Esc = 편집 끝(표에 초점 · 키 받음) · 편집 아닐 때 = 고름 풂
|
||||
if (event.key === "Escape") {
|
||||
if (!editing(at)) {
|
||||
picked = null;
|
||||
return live.sync();
|
||||
}
|
||||
(at as HTMLElement).blur();
|
||||
return live.root.focus();
|
||||
}
|
||||
const label = keyAct(event, editing(at));
|
||||
if (!label) return;
|
||||
event.preventDefault();
|
||||
live.run(label);
|
||||
});
|
||||
}
|
||||
|
||||
/** 표 위 레벨 도구 줄 — 표 root 에 줄 누름(고름)을 붙임 */
|
||||
export function levelBar(root: HTMLElement, table: BillTable): HTMLElement {
|
||||
const index = (): number =>
|
||||
picked === null ? -1 : table.rows.findIndex((r) => String(r.key) === picked);
|
||||
const run = (label: string): void => menuActs(table, index()).get(label)?.();
|
||||
const buttons = ACTS.map(([text, label]) => {
|
||||
const button = createButton({
|
||||
label: text,
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => run(label),
|
||||
});
|
||||
button.dataset.b09Level = label;
|
||||
return button;
|
||||
});
|
||||
const guide = document.createElement("span");
|
||||
guide.className = "b09-estimation__hint";
|
||||
guide.textContent = GUIDE;
|
||||
attachHoverInfo(guide, GUIDE_MORE);
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "b09-estimation__level";
|
||||
bar.append(...buttons, guide);
|
||||
const sync = (): void => {
|
||||
const acts = menuActs(table, index());
|
||||
ACTS.forEach(([, label], k) => (buttons[k].disabled = !acts.has(label)));
|
||||
for (const tr of root.querySelectorAll("tr.is-focus")) tr.classList.remove("is-focus");
|
||||
root.querySelector(`tr[data-i="${index()}"]`)?.classList.add("is-focus");
|
||||
};
|
||||
root.addEventListener("click", (event) => {
|
||||
const tr = (event.target as Element).closest<HTMLElement>("tr[data-i]");
|
||||
const row = tr ? table.rows[Number(tr.dataset.i)] : undefined;
|
||||
if (!row) return;
|
||||
picked = String(row.key);
|
||||
sync();
|
||||
});
|
||||
attachDrag(root, table, (key) => (picked = key));
|
||||
sync();
|
||||
live = { root, bar, run, sync };
|
||||
listenKeys();
|
||||
return bar;
|
||||
}
|
||||
@@ -1,14 +1,19 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Tabs_Bill_Link.ts
|
||||
* B09 설계내역서 연결 고르기(166-2 · 184-3) — 있는 호표(일위대가목록표 dg · 단가산출근거목록표 sg) ·
|
||||
* M01 로직(새 산근 = 서버 셈 · [저장] 뒤 단가) · 로직 입력값 고치기 · 연결 풀기.
|
||||
* 서고(마스터 · 회사 · 개인) 구조물조합 · 일위대가 사전조합(204-2 — 서버가 프로젝트 사본으로 들여와 새
|
||||
* 대가 호표 · 같은 것 = 있는 사본) · M01 로직(새 산근 = 서버 셈 · [저장] 뒤 단가) · 로직 입력값 고치기 ·
|
||||
* 연결 풀기.
|
||||
*
|
||||
* 줄에 적는 일 = 부르는 쪽 `apply(ref, from)`(`…_UI_Tabs_Bill_Edit` · 저장 꼴 모름)
|
||||
* 로직 고르기 · 입력값 = M02 창 재사용
|
||||
* 로직 · 사전조합 고르기 · 입력값 = M02 창 재사용 · 구조물조합 고르기 = B08 [선택] 나무(173) 재사용 ·
|
||||
* 층 읽기 권한 = 서버(`M02_Layer_Access`)
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { createFindList } from "@ui/ui_template_find_list";
|
||||
import { openItemPicker } from "@ui/ui_template_item_picker";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { fetchLogicInputs } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api";
|
||||
import { openLaborInput } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Input";
|
||||
@@ -86,6 +91,63 @@ function askInputs(apply: ApplyRef, code: string, name: string, old?: Row): void
|
||||
);
|
||||
}
|
||||
|
||||
/** 서고 것 → 프로젝트 사본 · 새 대가 호표(204-2) — 서버가 들여옴 → 받은 원가 자료(대가 블록 · 산근)만
|
||||
* 바꿔 끼우고(사용자 캐시 그대로) 줄 연결 `{tab: dg, key: 블록 id, lib: 사본 폴더}` */
|
||||
async function importLib(
|
||||
ctx: TabContext,
|
||||
apply: ApplyRef,
|
||||
pick: { kind: "combo" | "precombo"; layer: string; owner: string | null; key: string },
|
||||
): Promise<void> {
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
if (!projectId) return;
|
||||
showToast("서고에서 들여오는 중…", "info");
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/estimation/library`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(pick),
|
||||
});
|
||||
const got = (await response.json().catch(() => null)) as Row | null;
|
||||
if (!response.ok || !got) {
|
||||
const detail = got?.detail;
|
||||
throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`);
|
||||
}
|
||||
if (!got.key) throw new Error("사본은 들여왔으나 호표를 못 만듦");
|
||||
const { user: _user, ...rest } = got.payload as Row;
|
||||
Object.assign(ctx.payload, rest);
|
||||
const from = ((rest.sheets?.daega as Row[]) || []).find((r) => r.key === got.key);
|
||||
apply({ tab: "dg", key: got.key, lib: got.폴더 }, from);
|
||||
showToast(
|
||||
got.만듦 ? "새 호표로 연결함 — [저장]해야 연결이 남음" : "들여온 사본 호표로 연결함",
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
showToast(`서고에서 못 들여옴 — ${error instanceof Error ? error.message : error}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
/** 서고 구조물조합 — B08 [선택] 나무(마스터 · 회사 · 개인 · 보기만) */
|
||||
function pickCombo(ctx: TabContext, apply: ApplyRef): void {
|
||||
void openItemPicker({ current: null, canCopy: false }).then((got) => {
|
||||
if (got.action !== "apply") return;
|
||||
const { layer, owner, key } = got.item;
|
||||
void importLib(ctx, apply, { kind: "combo", layer, owner, key });
|
||||
});
|
||||
}
|
||||
|
||||
/** 서고 일위대가 사전조합 — M02 고르기 창 서고 줄만(층 단추 마스터 · 회사 · 개인) */
|
||||
function pickPrecombo(ctx: TabContext, apply: ApplyRef): void {
|
||||
openLogicPicker(() => undefined, {
|
||||
layer: "personal",
|
||||
owner: null,
|
||||
libOnly: true,
|
||||
title: "일위대가 사전조합 고르기",
|
||||
pick: (ref) =>
|
||||
void importLib(ctx, apply, { kind: "precombo", layer: ref.층, owner: ref.주인, key: ref.키 }),
|
||||
});
|
||||
}
|
||||
|
||||
/** 항목 줄 연결 메뉴 — [고르기 셋, 그 밖(로직 입력값 · 연결 풀기)] · ref = 지금 연결 */
|
||||
export function linkItems(ctx: TabContext, ref: Row | null, apply: ApplyRef): [Leaf[], Leaf[]] {
|
||||
const more: Leaf[] = [];
|
||||
@@ -95,6 +157,8 @@ export function linkItems(ctx: TabContext, ref: Row | null, apply: ApplyRef): [L
|
||||
const picks: Leaf[] = [
|
||||
["일위대가 호표", () => pickHopyo(ctx, apply, "dg")],
|
||||
["단가산출 호표", () => pickHopyo(ctx, apply, "sg")],
|
||||
["서고 구조물조합(마스터 · 회사 · 개인)", () => pickCombo(ctx, apply)],
|
||||
["서고 일위대가 사전조합(마스터 · 회사 · 개인)", () => pickPrecombo(ctx, apply)],
|
||||
[
|
||||
"단가산출 로직(M01 · 새 산근)",
|
||||
() => openLogicPicker((logic) => askInputs(apply, logic.키, logic.이름)),
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import type { DataRow } from "@ui/ui_template_data_table";
|
||||
import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
import {
|
||||
blockLevel,
|
||||
money,
|
||||
qty,
|
||||
text,
|
||||
@@ -25,7 +26,7 @@ import { raw } from "./B09_Estimation_UI_Tabs_Sangeun";
|
||||
const EMPTY =
|
||||
"연결 없음 — 연결된 구조물 조합 사본(일위대가)이 없음 · B05 · B06 에서 조합을 고르고 [저장]";
|
||||
/** 하위 줄 앞 — 들여쓰기 + 「ㄴ」(공용 표가 앞 빈칸을 접지 않음) */
|
||||
const CHILD = " ㄴ ";
|
||||
const CHILD = "ㄴ "; // 들여쓰기 = 공용 표 level(201-1)
|
||||
const ROUNDS = "줄 금액 ROUNDDOWN(단가 × 수량, 1) · % 줄 = 밑수 × % / 100 · 합계 ROUNDDOWN 0";
|
||||
const BLANK = "빈 줄 n = 금액 모르는 줄 · 합계 = 아는 줄 합 · 내역 단가엔 안 넘김";
|
||||
/** 「빈 줄 n」 작은 글 · 풍선 = 빈 줄 이름 */
|
||||
@@ -122,7 +123,7 @@ export const buildDaegaBlocks: TabBuild = ({ sheets }): TabTable | string => {
|
||||
titles.add(rows.length);
|
||||
rows.push({
|
||||
key: `${b.id}|합계`,
|
||||
name: " 합 계",
|
||||
name: "합 계",
|
||||
sum: b.sum,
|
||||
labor: b.labor,
|
||||
material: b.material,
|
||||
@@ -154,6 +155,7 @@ export const buildDaegaBlocks: TabBuild = ({ sheets }): TabTable | string => {
|
||||
hint: `단가 = B09 프로젝트 한 벌 굳힌 단가 · ${ROUNDS} · ${BLANK} · 줄 누름 = 단가를 낸 탭(대가 줄 = 그 블록)`,
|
||||
link: (i: number) => links[i] ?? null,
|
||||
rowClass: (i: number) => (titles.has(i) ? "is-title" : null),
|
||||
level: blockLevel("name", (i) => titles.has(i)),
|
||||
tip: (i: number, key: string) =>
|
||||
key === "spec"
|
||||
? raw(rows[i]?.spec, rows[i]?.spec_raw)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Tabs_Sangeun.ts
|
||||
* B09 단가산출근거 · 단가산출근거목록표 탭(PLAN 145-1) — 값 = 서버 `B09_Estimation_Sangeun`
|
||||
* (서버 단독 · 이 탭엔 조작 없음 · [저장] · [단가 갱신] 때 서버가 다시 셈).
|
||||
* (서버 단독 · 이 탭엔 조작 없음 · [저장] · [업데이트] 때 서버가 다시 셈).
|
||||
*
|
||||
* 목록표 = 산근 호표 한 줄(합계 · 노무비 · 재료비 · 경비 · 1 단위) · 줄 누름 = 그 블록
|
||||
* 풀이표(155-5 · 엑셀 단가산출근거 꼴) = 공종 · 산출내역(서버가 지은 텍스트 수식 한 칸) · 합계 ·
|
||||
@@ -13,6 +13,7 @@
|
||||
import type { DataRow } from "@ui/ui_template_data_table";
|
||||
import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
import {
|
||||
blockLevel,
|
||||
money,
|
||||
qty,
|
||||
text,
|
||||
@@ -99,7 +100,7 @@ export const buildSg: TabBuild = ({ sheets }): TabTable | string => {
|
||||
null,
|
||||
true,
|
||||
);
|
||||
head.slice(1).forEach((line, n) => push({ key: `${b.key}|머리${n}`, text: ` ${line}` }));
|
||||
head.slice(1).forEach((line, n) => push({ key: `${b.key}|머리${n}`, text: line }));
|
||||
(b.lines as Row[]).forEach((ln, n) => {
|
||||
const link = ln.ref?.key ? { tab: LINE_TAB[ln.ref.tab] ?? "compare", key: ln.ref.key } : null;
|
||||
const kind =
|
||||
@@ -115,8 +116,8 @@ export const buildSg: TabBuild = ({ sheets }): TabTable | string => {
|
||||
push(
|
||||
{
|
||||
key: `${b.key}|${n}${part ? `|${part}` : ""}`,
|
||||
kind: k === 0 ? kind : "",
|
||||
text: ` ${texts[k] ?? fallback}`,
|
||||
kind, // 같은 공종 줄 = 세로 병합(201-1)
|
||||
text: texts[k] ?? fallback,
|
||||
sum: PARTS.some((p) => at(p) === null)
|
||||
? null
|
||||
: PARTS.reduce((s, p) => s + (at(p) as number), 0),
|
||||
@@ -162,6 +163,8 @@ export const buildSg: TabBuild = ({ sheets }): TabTable | string => {
|
||||
notes(sheets),
|
||||
link: (i: number) => links[i] ?? null,
|
||||
rowClass: (i: number) => (titles.has(i) ? "is-title" : null),
|
||||
level: blockLevel("text", (i) => titles.has(i)),
|
||||
mergeSame: ["kind"],
|
||||
tip: (i: number, key: string) =>
|
||||
key === "note" ? raw(rows[i]?.note, rows[i]?.note_raw) : null,
|
||||
};
|
||||
|
||||
@@ -24,7 +24,7 @@ const HINT =
|
||||
/** 안내 + 「연결 없음 n」(전개 안 한 내역 항목 — 조합 밖 토공 · 운반 산근 등) */
|
||||
const hint = (payload: Row, extra = ""): string => {
|
||||
const n = (payload.summary || {}).unlinked || 0;
|
||||
return `${HINT}${extra}${n ? ` · 연결 없음 ${n} — 전개 안 함(연결 점검 탭)` : ""}`;
|
||||
return `${HINT}${extra}${n ? ` · 연결 없음 ${n} — 전개 안 함(좌측 데이터 점검)` : ""}`;
|
||||
};
|
||||
|
||||
/** 줄이 없을 때 표 위 맨 앞 안내 */
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
* 고칠 칸 = 요율 · 손입력(기초액 · 이윤 보정 · 부가세 조정 · 조달수수료율) ·
|
||||
* 엑셀에 없는 현행 비목 = 줄만(빈칸)
|
||||
* 원가계산서 = 총괄 값 거울 + 구성비 · 고칠 칸 = 간접재료비 · 작업설 · 줄 누름 = 총괄 그 줄로
|
||||
* 레벨(185-1) = 줄 `level`(화면이 정함 · 줄 짜임이 이 파일에 있음 · 셈 짝은 값만) → 182 is-lvN 글자 크기 ·
|
||||
* 들여쓰기 = 명칭 칸(is-indent) · 앞 칸(is-flat)은 그대로 · 1단 = 굵게(is-title)
|
||||
* 레벨(185-1 · 201-1) = 줄 `level`(화면이 정함 · 줄 짜임이 이 파일에 있음 · 셈 짝은 값만) →
|
||||
* 공용 표 level 옵션(명칭 칸 들여쓰기 · 보기 전용) · 1단 = 굵게(is-title)
|
||||
* 총괄 = 가.~바. 소계 1 · 그 밖 2 · 원가계산서 = 구분만 1 · 비목 소계 2 · 세목 3
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -77,13 +77,12 @@ function clickEstimate(ctx: TabContext, rowKey: string, column: string, from: un
|
||||
ctx.setUser({ ...ctx.user, estimate });
|
||||
}
|
||||
|
||||
/** 185-1 레벨 꼴 — 줄 class · 칸 class(들여쓰기 = 명칭 칸 · 앞 칸 그대로) */
|
||||
const levelClass = (r: DataRow | undefined): string | null =>
|
||||
r ? `is-lv${r.level}${r.level === 1 ? " is-title" : ""}` : null;
|
||||
const indentClass =
|
||||
(first: string, name: string) =>
|
||||
(_i: number, key: string): string | null =>
|
||||
key === name ? "is-indent" : key === first ? "is-flat" : null;
|
||||
/** 185-1 레벨 꼴 — 1단 = 굵게 · 들여쓰기 = 공용 표 level(명칭 칸) */
|
||||
const levelClass = (r: DataRow | undefined): string | null => (r?.level === 1 ? "is-title" : null);
|
||||
const nameLevel = (rows: DataRow[]) => ({
|
||||
key: "name",
|
||||
of: (i: number) => rows[i]?.level as number | undefined,
|
||||
});
|
||||
|
||||
function rateText(formula: string, rate: Num): string {
|
||||
return rate === null ? `${formula}(요율 빈칸)` : formula.replace("율", `${rate}%`);
|
||||
@@ -210,7 +209,8 @@ export const buildTotal: TabBuild = (ctx): TabTable => {
|
||||
rows,
|
||||
frozen: 2,
|
||||
rowClass: (i) => levelClass(rows[i]),
|
||||
cellClass: indentClass("part", "name"),
|
||||
level: nameLevel(rows),
|
||||
mergeSame: ["part"],
|
||||
hint:
|
||||
"요율 = M01 조달청 제비율에서 자동(맨 위 고르는 값 · 공종 토목) — 칸에 적으면 사용자 값이 앞섬 · 지우거나 출처 칸 누르면 자동 · " +
|
||||
"2024 엑셀 요율과 연도 차로 다를 수 있음 · 자동도 없으면 그 줄 · 그 줄을 쓰는 소계 빈칸 · 끝수 = 엑셀 그대로(줄마다 원 버림 · 관급 천원 올림) · " +
|
||||
@@ -290,7 +290,8 @@ export const buildCost: TabBuild = (ctx): TabTable => {
|
||||
rows,
|
||||
frozen: 3,
|
||||
rowClass: (i) => levelClass(rows[i]),
|
||||
cellClass: indentClass("group", "name"),
|
||||
level: nameLevel(rows),
|
||||
mergeSame: ["group", "part"],
|
||||
hint: "총괄설계내역서 값 거울 · 구성비 = 총공사비 대비 · 고칠 칸 = 간접재료비 · 작업설(직접재료비가 그만큼 바뀌고 재료비 소계는 그대로 — 엑셀 꼴) · 줄 누름 = 총괄 그 줄",
|
||||
editable: (i, key) => key === "amount" && String(rows[i]?.key) in HAND,
|
||||
onEdit: (i, key, value) => editEstimate(ctx, String(rows[i]?.key), key, value),
|
||||
|
||||
@@ -132,8 +132,6 @@ def logic_rows(row: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
def build_source(item: dict[str, Any]) -> dict[str, Any]:
|
||||
"""원본 층 · 마스터에서 항목 내용을 모음 — SS 줄이 없으면 LookupError."""
|
||||
from M02_MasterTemplete import M02_Item_Prices as prices
|
||||
|
||||
root = layer_root(item)
|
||||
row = row_of(read_json(root / layers.COMBO_FILE), item["key"])
|
||||
if row is None:
|
||||
@@ -153,6 +151,14 @@ def build_source(item: dict[str, Any]) -> dict[str, Any]:
|
||||
labor = labor_rows(str(row["일위대가"])) if row.get("일위대가") else None
|
||||
if labor is not None:
|
||||
files[LABOR_FILE] = labor
|
||||
files[PRICE_FILE] = price_bundle(labor, root)
|
||||
return files
|
||||
|
||||
|
||||
def price_bundle(labor: dict[str, Any] | None, root: Path) -> dict[str, Any]:
|
||||
"""사본 단가 묶음(`단가.json`) — 담은 로직 · 굳힌 참조 로직 · 자재 · 고른 자재 · 사전조합 자재 ·
|
||||
개인 자재(`root` = 원본 층 · B09 설계내역서 서고 사본도 이 길 · 204-2)."""
|
||||
from M02_MasterTemplete import M02_Item_Prices as prices
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
|
||||
logic_keys = [str(one.get("키")) for one in (labor or {}).get("로직", [])]
|
||||
@@ -164,9 +170,9 @@ def build_source(item: dict[str, Any]) -> dict[str, Any]:
|
||||
]
|
||||
materials += refs.frozen_materials((labor or {}).get("참조사본") or {}) # 175-2 사전조합 자재
|
||||
wanted = logic_keys + [code for code in materials if code]
|
||||
files[PRICE_FILE] = prices.to_json(prices.collect(logic_keys, materials)) if wanted else {}
|
||||
bundle = prices.to_json(prices.collect(logic_keys, materials)) if wanted else {}
|
||||
if mine := own.bundle(
|
||||
[c for c in materials if own.is_own(c)], root
|
||||
): # 개인 자재(148-3 · 사전조합 것도 175-2) — 원본 목록이 바뀌어도 사본 그대로
|
||||
files[PRICE_FILE][own.BUNDLE] = mine
|
||||
return files
|
||||
bundle[own.BUNDLE] = mine
|
||||
return bundle
|
||||
|
||||
@@ -34,6 +34,9 @@ export interface LibraryPick {
|
||||
layer: Layer;
|
||||
owner: string | null;
|
||||
pick: (ref: LaborRef, name: string) => void;
|
||||
/** 서고 줄만(로직 목록 안 받음 · B09 설계내역서 연결 204-2) · 창 제목 */
|
||||
libOnly?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/** 고를 수 있는 층 — 위 층만(마스터 → 마스터 · 회사 → 그 회사 · 마스터 · 개인 → 그 개인 · 그 회사 · 마스터) */
|
||||
@@ -213,7 +216,7 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra
|
||||
el("div", {
|
||||
className: "m01-logic__section-head",
|
||||
children: [
|
||||
el("h3", { text: tc("Pick_Title") }),
|
||||
el("h3", { text: lib?.title ?? tc("Pick_Title") }),
|
||||
createButton({ label: tc("Pick_Close"), variant: "ghost", onClick: close }),
|
||||
],
|
||||
}),
|
||||
@@ -226,14 +229,15 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra
|
||||
document.body.append(backdrop);
|
||||
finder.search.focus();
|
||||
|
||||
void Promise.all([fetchLogicSubs(), fetchLogics()])
|
||||
.then(([s, l]) => {
|
||||
subs.push(...s);
|
||||
all = l;
|
||||
drawSubs();
|
||||
find();
|
||||
})
|
||||
.catch((error) =>
|
||||
showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"),
|
||||
);
|
||||
if (!lib?.libOnly)
|
||||
void Promise.all([fetchLogicSubs(), fetchLogics()])
|
||||
.then(([s, l]) => {
|
||||
subs.push(...s);
|
||||
all = l;
|
||||
drawSubs();
|
||||
find();
|
||||
})
|
||||
.catch((error) =>
|
||||
showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -188,8 +188,11 @@ export class SectionGeometry {
|
||||
// `hasDitch`** 로 가른다.
|
||||
// ⚠ 옛 저장분은 `ditchEnabled` 로 온다. 그 값은 **자동값과 다를 때만 뜻이 있다** —
|
||||
// 같으면 자동이 그렇게 냈던 것이고, 다르면 사용자가 일부러 바꾼 것이다.
|
||||
// ⚠ 양측 성토는 **판정된 역할**로 가른다(206-1 · 짝: 파이썬) — 입력 `sectionMode` 는 옛
|
||||
// 저장값일 수 있다. 입력으로 가르면 옛 측구 결과가 선택으로 둔갑해 양측 성토에 측구가 섰다.
|
||||
const bothFill = this.leftRole === "fill" && this.rightRole === "fill";
|
||||
let autoDitch: boolean;
|
||||
if (params.sectionMode === "both_fill") {
|
||||
if (bothFill) {
|
||||
autoDitch = false;
|
||||
} else if (groundAt !== null) {
|
||||
const ditchEdge = params.ditchSide === "left" ? this.leftExtent : -this.rightExtent;
|
||||
@@ -206,10 +209,10 @@ export class SectionGeometry {
|
||||
// 「자동으로 되돌리기」 단추가 없고, 원래 값으로 다시 누른 것을 선택으로 굳히면
|
||||
// **다시 같은 병**이 된다.
|
||||
if (choice !== null && Boolean(choice) === autoDitch) choice = null;
|
||||
// 양성(both_fill)은 측구가 설 자리가 없다 — 선택보다 기하가 먼저다.
|
||||
this.hasDitch = choice === null || params.sectionMode === "both_fill" ? autoDitch : choice;
|
||||
// 양성(both_fill)은 측구가 설 자리가 없다 — 선택보다 기하가 먼저 · 남은 선택은 비움.
|
||||
this.hasDitch = choice === null || bothFill ? autoDitch : choice;
|
||||
/** 그 측점에 **사용자가 정한 선택**(없으면 자동). 결과와 갈라 내보낸다. */
|
||||
this.ditchChoice = choice;
|
||||
this.ditchChoice = bothFill ? null : choice;
|
||||
|
||||
// 설계선 쉬프트 = 쪽마다 사면 이동(≥ 0 · 139-6 · 151-2) — 단면유형 · 측구 판정은 **옮기기 전**
|
||||
// 노견에서(위) · 노견(폭 고정) · 측구 · 사면이 같이 바깥으로 · 늘어난 폭은 차도. ⚠ 파이썬 짝 `_SectionGeometry`.
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# 조사 AB — 제비율 대조 (요구 58 · 조사만)
|
||||
|
||||
대상 = B09 총괄설계내역서 · 공사원가계산서 탭의 요율 줄 ↔ 원문 조달청 제비율표(2026.04.13판, 토목 갈래) · 예정가격작성기준 별표2 · 실무 엑셀(기번3 2024 · 소광 2025 · 거창 2026).
|
||||
|
||||
## 1. 한눈 결론
|
||||
|
||||
- 요율 값 자체는 맞음 — 프로그램이 자동으로 고르는 13줄(간접노무비~부가세)의 값 · 밑수 · 구간이 원문표와 모두 같음.
|
||||
- 큰 누락 넷 — 퇴직공제부금비 · 임금채권부담금 · 석면분담금 · 하도급대금지급보증수수료. 줄 이름만 있고 늘 빈칸이며 소계에도 안 들어감(M01 표엔 값이 있는데 셈이 안 씀).
|
||||
- 총괄 줄 자체가 없는 것 둘 — 폐기물처리비(실무 거창 · 소광에 있고, 09-07 사용자 확정 「필요」) · 공사손해보험료(원문 서식에 있음, 가입 대상일 때만).
|
||||
- 조건이 다른 것 — 연금 요율을 「오늘 날짜의 해」로 고름(설계 연도 칸 없음) · 조달수수료율 자동값 없음(빈칸 = 0) · 적용 조건(1억 이상 등) 확인 없음.
|
||||
- 실무 엑셀 차례는 기번3(2024) 꼴과 같음. 2026 실무(거창)에 비하면 퇴직공제 · 공사이행보증 · 폐기물처리비 줄이 빠짐.
|
||||
|
||||
## 2. 대조 표
|
||||
|
||||
프로그램 근거 약칭 — `CT` = `B09_Estimation/B09_Estimation_Calc_Total.py`(짝 `.ts` 같은 줄 구성) · `RT` = `B09_Estimation/B09_Estimation_Rates.py` · `UI` = `B09_Estimation/B09_Estimation_UI_Tabs_Total.ts` · `M01` = `resources/master_data/요율_조달청제비율.json` · 원문 = `현행_20260413.md` 줄.
|
||||
|
||||
| 원문 비목 | 원문 요율 · 밑수 · 조건 | 프로그램 | 프로그램 근거 | 메모 |
|
||||
|---|---|---|---|---|
|
||||
| 간접노무비 (원문 8-31) | 직노 × 18.9~21.0% · 직접공사비 5구간 × 공기 4구간 · 토목 | 있음 · 같음 | CT:34,179 · RT:136 | 직접공사비 = 가.순공사비계 · 공기 = 사용자 칸(일) · 183/365/1095일 경계 맞음 |
|
||||
| 산재보험료 (원문 88) | 노무비 × 3.56% · 모든 건설공사 | 있음 · 같음 | CT:35,181 · RT:112 | 밑수 = 직노 + 간노 |
|
||||
| 고용보험료 (원문 91-105) | 노무비 × 1.01~1.57% · **추정금액** 등급 | 있음 · 밑수 조금 다름 | CT:36,182 · RT:127 | 등급을 **추정가격** 칸으로 고름. 임도 규모는 둘 다 7등급 이하 1.01% → 금액 차 없음 |
|
||||
| 건강보험료 (원문 85) | 직노 × 3.595% · 공기 1개월 이상 | 있음 · 같음 | CT:38,183 · RT:112 | 1개월 조건 확인 없음(임도는 늘 넘음) |
|
||||
| 노인장기요양 (원문 86) | 건강보험료 × 13.14% | 있음 · 같음 | CT:39,184 · RT:112 | |
|
||||
| 연금보험료 (원문 87) | 직노 × 4.75%(2026) · 해마다 오름(2027 5.00 …) | 있음 · **조건 다름** | CT:40,185,158 · RT:155-159 | 해 = 오늘 날짜의 해 → 2026 설계를 2027년에 다시 [저장]하면 5.00%로 바뀜 |
|
||||
| 산업안전보건관리비 (원문 107-125) | (재료비 + 직노 + 도급자설치 관급) × 3.15% / 2.53% + 330만 / 2.60% · 관급 있으면 A · B(× 1.2) 작은 쪽 · 총공사금액 2천만 이상 | 있음 · 조건 일부 다름 | CT:40,186-192 · RT:139-154 | B안도 A 대상액 구간의 요율 · 기초액을 씀(고시는 「관급 뺀 대상액으로 산출」 → 5억 · 50억 경계 근처에서만 차이) · 2천만 조건 확인 없음 · 1000억↑ 구간은 뺌(임도 밖) |
|
||||
| 기타경비 (원문 33-58) | (재료비 + 노무비) × 5.5~8.1% · 직접공사비 × 공기 | 있음 · 같음 | CT:41,193 · RT:137 | |
|
||||
| 환경보전비 (원문 127-143) | 직접공사비 × 0.9(도로) / 0.8(그 밖의 토목) · 추정금액 1억 이상 | 있음 · 조건 확인 없음 | CT:42,194 · RT:160-169 · UI:156-162 | 기본 = 그 밖 0.8 · 누르면 도로 0.9(지식DB도 미확정) · 1억 미만 공사에도 붙음 |
|
||||
| 건설기계대여대금 지급보증 (원문 171-173) | 직접공사비 × 0.4%(종합 토목) · 전문 0.1~0.68% | 있음 · 같음 | CT:43,195 · RT:115-126 | 거창 실무 0.1%(전문) — 사용자 칸으로 바꿀 수 있음 |
|
||||
| **퇴직공제부금비** (원문 152-154) | 직노 × 2.3% · 추정금액 1억 이상 | **줄만 · 늘 빈칸 · 소계 안 듦** | CT:46-52 · UI:169-173 · M01:1027 | ★ 누락. 거창 2026 실무 계상(5,603,907원). 원가계산서 탭엔 줄도 없음(UI:236-257) |
|
||||
| **임금채권부담금** (원문 150) | 노무비 × 0.09% · 모든 건설공사 | **줄만** | CT:48 · M01:1028 | ★ 누락 (2026.03.13 추가 항목) |
|
||||
| **석면분담금** (원문 149) | 노무비 × 0.006% · 모든 건설공사 | **줄만** | CT:49 · M01:1029 | ★ 누락 (〃) |
|
||||
| **하도급대금지급보증수수료** (원문 158-169) | 직접공사비 × 0.081%(추정가격 50억 미만) … · 전문공사 제외 | **줄만** | CT:50 · M01:1031 | ★ 누락. 임도를 종합공사로 내면 붙음 |
|
||||
| 공사이행보증수수료 (원문 175-185) | 직공비 구간식 × 공기(년) · 추정가격 300억 이상 등 의무 대상만 | 줄만 | CT:51 · M01:1110 | 임도는 통상 비대상 → 빈칸이 맞음. 원가계산서 탭엔 줄 없음 |
|
||||
| 일반관리비 (원문 60-70) | (재 + 노 + 경) × 8.0%(50억 미만) / 6.5 / 5.0 / 4.5 · 추정가격 | 있음 · 같음 | CT:55,197 · RT:128-134 | 원문 md 표는 병합 칸이라 「5억~50억」이 비어 보이나 실무 「26. 1. 1.」 시트로 50억 미만 = 8.0 확인 → M01 해석 맞음 |
|
||||
| 이윤 (원문 72-79) | (노 + 경 + 일반관리비) × 15 / 12 / 10 / 9% · 추정가격 · 상한 15%(예정가격작성기준 §21) | 있음 · 같음 | CT:56,199-200 · RT:135 | 사용자가 15% 넘게 적어도 막지 않음 |
|
||||
| 부가가치세 | 총원가 × 10% | 있음 · 같음 | CT:57,202-203 · RT:114 | |
|
||||
| 조달수수료 (조달수수료 고시 22-33) | 관급 순자재대 × 0.54%(단가계약 · 10억 이하) | 있음 · **자동값 없음** | CT:206-210 · UI:40 | 빈칸 시작 = 0 → 관급이 있어도 수수료 0. M01에 줄 없음 |
|
||||
| 공사손해보험료 (예정가격작성기준 §22 · 별표2) | 가입 대상 총원가(+ 관급) × 발주기관이 정한 율 | **없음** | — | 조달청 제비율표 밖 · 가입 대상일 때만 · 실무 셋 모두 없음 |
|
||||
| 폐기물처리비 (실무 거창 · 소광) | 요율 아님 · 실비 | **없음** | — | 거창 = 관급 뒤(총원가 밖) · 소광 = 이윤 뒤(총원가 안) — 자리 갈림 |
|
||||
| 간접재료비 · 작업설 | 손입력 | 있음 | UI:41-42 | |
|
||||
|
||||
### 2-2. 실무 엑셀 차례와 비교
|
||||
|
||||
| 실무 | 경비 줄 차례 | 프로그램과 차이 |
|
||||
|---|---|---|
|
||||
| 기번3 2024 총괄 49~67 (STmate) | 간노 · 산재 · 고용 · 건강 · 요양 · 연금 · 안전 · 기타경비 · 환경 · 건설기계 → 나.소계 · 일반관리비 · 이윤 · 부가세 · 관급 | **같음** (CT:33-58이 이 차례를 그대로 따름) |
|
||||
| 소광 2025 원가계산서 (STmate) | 기번3과 같음 + 이윤 뒤 「폐기물처리」 | 폐기물처리 줄 없음 |
|
||||
| 거창 2026 원가계산서 (26.1.1판) | … 연금 · 요양 · **퇴직공제** · 건설기계 · 안전(A · B 두 줄) · 환경 · **공사이행보증** · 기타경비 · … 관급 뒤 **폐기물처리비** | 퇴직공제 값 · 공사이행보증 줄(원가계산서) · 폐기물처리비 · 안전 비채택 줄(프로그램은 산식 글로만 A · B 표시 UI:191-193) |
|
||||
|
||||
- 원문 4종(퇴직공제 · 임금채권 · 석면 · 하도급)은 실무 셋 중 거창에 퇴직공제만 있음 — 실무를 따라도, 원문을 따라도 프로그램에서 빠진 상태.
|
||||
|
||||
## 3. 누락 · 다름 목록과 고칠 거리
|
||||
|
||||
| # | 무엇 | 왜 문제 | 고칠 거리 | 크기 |
|
||||
|---|---|---|---|---|
|
||||
| A | 법정 비목 4개 셈 안 함 (퇴직공제 · 임금채권 · 석면 · 하도급) | 원문상 모든(또는 1억 이상) 임도에 붙는 돈이 빠짐. 직노 2.4억 공사(거창)면 퇴직공제만 약 560만 원 + 일반관리비 · 이윤 · 부가세 몫 | CURRENT_ONLY → RATE_LINES로 옮김 · `rate_table` 에 4줄(값 · 밑수는 M01에 이미 있음) · 원가계산서 줄 · 짝 TS · 거울 시험 | 중 |
|
||||
| B | 폐기물처리비 줄 없음 | 사용자가 「필요」로 확정한 줄. 실무 두 벌에 있음 | 손입력 줄 하나 · **자리(총원가 안/밖) 사용자 협의 먼저** | 소~중 |
|
||||
| C | 조달수수료율 자동값 없음 | 관급자재가 있는데 칸을 안 채우면 관급자재대가 수수료만큼 적게 나옴 | M01에 조달수수료 줄(0.54%) 추가 후 자동 · 또는 기본값 | 소 |
|
||||
| D | 연금 요율 해 = 오늘 해 | 해를 넘겨 다시 저장하면 요율이 저절로 바뀜 | 프로젝트 기준 연도(설계 · 입찰 연도) 칸으로 고름 | 소 |
|
||||
| E | 적용 조건 확인 없음 (환경보전비 · 퇴직공제 추정금액 1억 · 안전 2천만 · 건강 · 연금 1개월) | 소규모 공사에서 0 이어야 할 줄이 붙음 | M01 조건 칸(이미 있음 M01:113,954,1027)을 셈이 읽음 | 소 |
|
||||
| F | 안전관리비 B안 구간 | 관급을 넣어 대상액이 5억 · 50억을 넘을 때 B안 요율이 원문과 다를 수 있음 | B안은 관급 뺀 대상액으로 구간 다시 고름 | 소 |
|
||||
| G | 공사손해보험료 줄 없음 | 가입 대상 공사일 때 넣을 자리 없음 | 손입력 줄(율 · 기본 빈칸) | 소 |
|
||||
| H | 고용보험 등급 밑수 = 추정가격 | 원문은 추정금액. 임도 규모에선 금액 차 없음 | 메모만 · 또는 추정금액 칸 | 소 |
|
||||
| I | M01 처리규칙 글 「추정가격 빈칸이면 직접공사비로 수렴」 ↔ 코드는 빈칸이면 자동 없음(CT:93-95) | 글과 코드가 어긋남 → 추정가격을 안 적으면 일반관리비 · 이윤 · 고용 빈칸 | 글을 고치거나 수렴을 넣음(협의) | 소 |
|
||||
| J | 이윤 15% · 일반관리비 상한 확인 없음 | 사용자 값이 상한을 넘어도 그대로 | 넘으면 출처 칸에 경고 글 | 소 |
|
||||
|
||||
- 요율 값 오류 · 밑수 오류는 못 찾음 (자동 고르는 13줄 전부 원문과 같음).
|
||||
- 원문은 조달청 발주 기준(「참고자료」 문구) — 지자체 · 산림청 발주용 별도 제비율은 지식DB에 없음. 지식DB는 근거 · 후보 가이드 — 위 고칠 거리의 값 · 자리는 사용자 협의로 확정.
|
||||
|
||||
## 4. 근거 문서
|
||||
|
||||
- 원문 제비율 — `resources/knowledge/original/행정규칙/토목공사원가계산 제비율 적용기준/현행_20260413.md` (원본 xlsx 같은 폴더 `첨부/`)
|
||||
- 원가 서식 · 비목 정의 — `resources/knowledge/original/행정규칙/(계약예규) 예정가격작성기준/현행_20260825.md` §19~22 · `별표/별표2_공사원가계산서.pdf`
|
||||
- 안전관리비 A · B — `resources/knowledge/original/행정규칙/건설업 산업안전보건관리비 계상 및 사용기준/현행_20250212.md` 44줄
|
||||
- 조달수수료 — `resources/knowledge/original/행정규칙/조달수수료 고시/현행_20250701.md` 22-33줄
|
||||
- 지식DB 정리 — `resources/knowledge/technical_info/01_임도/05_원가정보/제비율.md` · `원가계산_체계.md` · `original/실무문서/_종합비교/01_원가_제비율.md`
|
||||
- 실무 엑셀 — 기번3 `2024년 간선임도(기번3-울진.대흥)(공통)/…xlsx` (공사원가계산서 · 총괄설계내역서 49~67) · 소광 `25년 산불진화임도(기번8)(울진소광)/…xlsx` (공사원가계산서) · 거창 `01.산불거창(거창북상병곡)/01.내역서/…xlsx` (원가계산서 · 「26. 1. 1.」 시트)
|
||||
- 이전 결정 — `docs/raw/plans/2026-09-08_plan_B08_실무자료_해독기록.md` 8-13 · 8-14 (퇴직공제 · 공사이행보증 · 폐기물처리비 「필요」 사용자 확정 · 법정 4종 발견)
|
||||
- 프로그램 — `B09_Estimation/B09_Estimation_Calc_Total.py` · `.ts` · `B09_Estimation_Rates.py` · `B09_Estimation_UI_Tabs_Total.ts` · `B09_Estimation_Store.py` 41-52 · `resources/master_data/요율_조달청제비율.json`
|
||||
@@ -1,6 +1,6 @@
|
||||
"""144-1 B09 뼈대 — 탭 차례 · 저장 자리 · 길 · 빈칸 규칙 · 「연결 없음」 목록 · 단가 굳히기.
|
||||
|
||||
- 탭 = 기번3 공통 엑셀 보이는 탭 차례(목차 · 안내 = 「연결 점검」 한 탭 · INITIAL 안 옮김)
|
||||
- 탭 = 기번3 공통 엑셀 보이는 탭 차례(목차 · 안내 = 좌측 「데이터 점검」 묶음 203-1 · INITIAL 안 옮김)
|
||||
- 저장 = 프로젝트 안 `B09_Estimation/v1/`(사용자 값 · 굳힌 단가) · 결과는 저장 안 함
|
||||
- 줄 = 수요 따라(사용자 승인 ①) — 연결된 사본 없으면 노무 · 경비 · 중기 빈 표
|
||||
· B08 자재만 줄 · 출처 빈칸
|
||||
@@ -45,8 +45,7 @@ def test_탭_차례가_엑셀과_같다():
|
||||
source.index("export const TABS") : source.index("];", source.index("export const TABS"))
|
||||
]
|
||||
names = re.findall(r'\["\w+", "([^"]+)"\]', block)
|
||||
assert names[0] == "연결 점검"
|
||||
assert names[1:] == EXCEL_TABS
|
||||
assert names == EXCEL_TABS # 203-1 「연결 점검」 탭 = 좌측 묶음으로 옮김
|
||||
|
||||
|
||||
def test_길_셋():
|
||||
|
||||
@@ -52,16 +52,18 @@ def test_155_2_일위대가표_제목_굵게_하위_ㄴ():
|
||||
out = _node(
|
||||
f"\nconst t = buildDaegaBlocks({{ sheets: {{ daega_blocks: [{json.dumps(BLOCK)}] }} }});"
|
||||
"\nconsole.log(JSON.stringify({ names: t.rows.map((r) => r.name),"
|
||||
" cls: t.rows.map((_, i) => t.rowClass(i) ?? null) }));"
|
||||
" cls: t.rows.map((_, i) => t.rowClass(i) ?? null),"
|
||||
" lv: [t.level.key, ...t.rows.map((_, i) => t.level.of(i))] }));"
|
||||
)
|
||||
assert out["names"] == [
|
||||
"제 1 호표 찰쌓기",
|
||||
" ㄴ 건설표준품셈 7-1-2",
|
||||
" ㄴ 석공",
|
||||
" ㄴ 뒷채움",
|
||||
" 합 계",
|
||||
]
|
||||
"ㄴ 건설표준품셈 7-1-2",
|
||||
"ㄴ 석공",
|
||||
"ㄴ 뒷채움",
|
||||
"합 계",
|
||||
] # 201-1 들여쓰기 = 공용 표 level(앞 빈칸 없앰)
|
||||
assert out["cls"] == ["is-title", None, None, None, "is-title"]
|
||||
assert out["lv"] == ["name", 1, 2, 2, 2, 1]
|
||||
|
||||
|
||||
SG = {
|
||||
@@ -92,12 +94,12 @@ def test_155_5_단가산출근거_엑셀_꼴_칸():
|
||||
rows = out["rows"]
|
||||
assert [(r.get("kind"), r["text"]) for r in rows] == [
|
||||
("제 1 호표", "뒷채움 / m3"),
|
||||
(None, " 산림품셈 13-3-2 적용"),
|
||||
(None, " 규격 : 150 mm"),
|
||||
("노무", " 보통인부 : 165,545 * 0.018 = 2,979.8"),
|
||||
("중기", " 굴착기 노무비 : 55,700 * 0.07 = 3,899.0"),
|
||||
("", " 굴착기 재료비 : 7,695 * 0.07 = 538.6"),
|
||||
("", " 굴착기 경비 : 13,041 * 0.07 = 912.8"),
|
||||
(None, "산림품셈 13-3-2 적용"),
|
||||
(None, "규격 : 150 mm"),
|
||||
("노무", "보통인부 : 165,545 * 0.018 = 2,979.8"),
|
||||
("중기", "굴착기 노무비 : 55,700 * 0.07 = 3,899.0"),
|
||||
("중기", "굴착기 재료비 : 7,695 * 0.07 = 538.6"), # 201-1 같은 공종 = 세로 병합
|
||||
("중기", "굴착기 경비 : 13,041 * 0.07 = 912.8"),
|
||||
("총 계", "계 : 노무비 6,878 + 재료비 538 + 경비 912 = 8,328"),
|
||||
]
|
||||
assert [r.get("sum") for r in rows[3:7]] == [2979.8, 3899.0, 538.6, 912.8]
|
||||
|
||||
@@ -229,8 +229,9 @@ console.log(JSON.stringify(out));
|
||||
assert out["title"] == [*add, "아래에 하위 줄 추가", "새 공종 추가(1단)", "-", "줄 지우기"]
|
||||
assert out["auto"] == [*add, "새 공종 추가(1단)", "-", "연결", "-", "아래로 ↓", "내어쓰기 ←",
|
||||
"-", "자동값으로 되돌리기", "줄 지우기"] # fmt: skip
|
||||
# 204 — 위 항목 아래로 들여쓰기 됨(그 항목 = 저절로 묶음)
|
||||
assert out["hand"] == [*add, "새 공종 추가(1단)", "-", "연결", "연결 풀기", "-", "위로 ↑",
|
||||
"내어쓰기 ←", "묶음 줄로 바꾸기", "-", "줄 지우기"] # fmt: skip
|
||||
"들여쓰기 →", "내어쓰기 ←", "묶음 줄로 바꾸기", "-", "줄 지우기"] # fmt: skip
|
||||
# 위에 줄 = 같은 부모 앞 · 옛 꼴 → v2(옛 손 줄 칸 · 수량 덮개 옮김)
|
||||
above = out["above"]
|
||||
assert [e["key"][:5] for e in above["rows"]] == ["sec:1", "sec:1", "hand:", "earth", "hand:"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""181-1 B09 탭 두 층 — 위 탭 10 · 아래 탭 묶음(사용자 30번) · 줄 그리기 · 마지막 본 아래 탭.
|
||||
"""181-1 B09 탭 두 층 — 위 탭 9(203-1 연결 점검 뺌) · 아래 탭 묶음(사용자 30번) · 줄 그리기 · 마지막 본 아래 탭.
|
||||
|
||||
- 위 탭 = `TAB_GROUPS`(`TABS` 19 키를 빠짐 · 겹침 없이 묶음) · `TABS`(엑셀 차례)는 그대로
|
||||
- 위 탭 = `TAB_GROUPS`(`TABS` 18 키를 빠짐 · 겹침 없이 묶음) · `TABS`(엑셀 차례)는 그대로
|
||||
- 줄 = 위 md · 아래 sm · 아래 탭 없는 위 탭 = 빈 보조 줄(B08 꼴)
|
||||
"""
|
||||
|
||||
@@ -18,7 +18,6 @@ BAR = "B09_Estimation/B09_Estimation_UI_TabBar.ts"
|
||||
|
||||
# 사용자 30번 — 위 탭 차례 = 지금 탭 차례(묶음 첫 자리) · 아래 = 글에 적힌 차례
|
||||
EXPECTED = [
|
||||
["연결 점검", ["check"]],
|
||||
["공사원가계산서", ["cost"]],
|
||||
["총괄설계내역서", ["total"]],
|
||||
["설계내역서", ["bill"]],
|
||||
@@ -31,7 +30,7 @@ EXPECTED = [
|
||||
]
|
||||
|
||||
|
||||
def test_묶음_표_사용자_30번과_같고_19_탭이_한_번씩():
|
||||
def test_묶음_표_사용자_30번과_같고_18_탭이_한_번씩():
|
||||
out = run(
|
||||
[TABS],
|
||||
"\nconsole.log(JSON.stringify({ groups: TAB_GROUPS, ids: TABS.map(([t]) => t),"
|
||||
@@ -39,11 +38,11 @@ def test_묶음_표_사용자_30번과_같고_19_탭이_한_번씩():
|
||||
)
|
||||
assert out["groups"] == EXPECTED
|
||||
flat = [t for _, tabs in out["groups"] for t in tabs]
|
||||
assert sorted(flat) == sorted(out["ids"]) and len(flat) == len(set(flat)) == 19
|
||||
assert sorted(flat) == sorted(out["ids"]) and len(flat) == len(set(flat)) == 18
|
||||
# 위 탭 차례 = 엑셀 차례에서 묶음이 처음 나오는 자리
|
||||
firsts = list(dict.fromkeys(out["of"]))
|
||||
assert firsts == [g[0] for g in EXPECTED]
|
||||
assert len(firsts) == 10
|
||||
assert len(firsts) == 9
|
||||
|
||||
|
||||
def test_줄_그리기_두_층_빈_줄_마지막_본_아래_탭():
|
||||
@@ -61,26 +60,26 @@ const view = () => ({
|
||||
});
|
||||
bars.draw('dg');
|
||||
const a = view();
|
||||
bars.top.children[4].click(); // 켜진 위 탭 다시 누름 = 아무 일 없음
|
||||
bars.top.children[3].click(); // 켜진 위 탭 다시 누름 = 아무 일 없음
|
||||
bars.sub.children[0].click(); // 일위대가목록표
|
||||
bars.draw('compare'); // 묶음을 넘는 이동 = 위 탭 따라 켜짐
|
||||
const b = view();
|
||||
bars.draw('sum_material');
|
||||
bars.draw('dg');
|
||||
bars.top.children[8].click(); // 재료 = 마지막 본 아래 탭(재료비수량금액집계표)
|
||||
bars.top.children[7].click(); // 중기 = 본 적 없음 → 첫 아래 탭
|
||||
bars.top.children[7].click(); // 재료 = 마지막 본 아래 탭(재료비수량금액집계표)
|
||||
bars.top.children[6].click(); // 중기 = 본 적 없음 → 첫 아래 탭
|
||||
bars.draw('cost');
|
||||
const c = view();
|
||||
bars.top.children[0].click(); // 연결 점검 = 단독 위 탭
|
||||
bars.top.children[1].click(); // 총괄설계내역서 = 단독 위 탭
|
||||
bars.sub.children[0].click(); // 빈 줄 단추(숨김) = 누름 없음
|
||||
console.log(JSON.stringify({ a, b, c, picks }));
|
||||
""",
|
||||
)
|
||||
a, b, c = out["a"], out["b"], out["c"]
|
||||
assert len(a["top"]) == 10 and [t[0] for t in a["top"]][:5] == [
|
||||
"연결 점검", "공사원가계산서", "총괄설계내역서", "설계내역서", "일위대가"
|
||||
assert len(a["top"]) == 9 and [t[0] for t in a["top"]][:5] == [
|
||||
"공사원가계산서", "총괄설계내역서", "설계내역서", "일위대가", "단가산출"
|
||||
] # fmt: skip
|
||||
assert [t[2] for t in a["top"]] == ["false"] * 4 + ["true"] + ["false"] * 5
|
||||
assert [t[2] for t in a["top"]] == ["false"] * 3 + ["true"] + ["false"] * 5
|
||||
assert all("ui-btn--ghost" in t[1] for t in a["top"] if t[2] == "false")
|
||||
assert a["sub"] == [["일위대가목록표", "false", "dg_list"], ["일위대가표", "true", "dg"]]
|
||||
assert not a["blank"] and a["role"] == "tablist"
|
||||
@@ -89,11 +88,11 @@ console.log(JSON.stringify({ a, b, c, picks }));
|
||||
["자재단가대비표", "true", "compare"],
|
||||
["재료비수량금액집계표", "false", "sum_material"],
|
||||
] # fmt: skip
|
||||
assert b["top"][8][2] == "true" and b["top"][4][2] == "false"
|
||||
assert b["top"][7][2] == "true" and b["top"][3][2] == "false"
|
||||
# 단독 위 탭 — 위 단추가 탭 키도 가짐 · 아래 줄 = 숨긴 단추 한 개
|
||||
assert c["blank"] and c["role"] == "presentation" and len(c["sub"]) == 1
|
||||
assert c["top"][1][3] == "cost" and c["top"][1][4] == "cost" and c["top"][4][4] is None
|
||||
assert out["picks"] == ["dg_list", "sum_material", "machine", "check"]
|
||||
assert c["top"][0][3] == "cost" and c["top"][0][4] == "cost" and c["top"][3][4] is None
|
||||
assert out["picks"] == ["dg_list", "sum_material", "machine", "total"]
|
||||
|
||||
|
||||
def test_잇기_쉘이_두_줄을_쓰고_CSS_빈_줄_규칙():
|
||||
|
||||
@@ -281,6 +281,7 @@ const out = {
|
||||
connect: ["hand:a", "hand:c", "hand:g1", "earth:4"].map((k) => table.rows[at(k)].connect),
|
||||
link: [table.link(at("hand:a"), "connect"), table.link(at("hand:a"), "name")],
|
||||
cls: ["sec:1", "hand:g1", "hand:a", "earth:4"].map((k) => table.rowClass(at(k))),
|
||||
lv: [table.level.key, ...["sec:1", "hand:g1", "hand:a", "earth:4"].map((k) => table.level.of(at(k)))],
|
||||
edit: [table.editable(at("hand:g1"), "name"), table.editable(at("hand:g1"), "qty")],
|
||||
};
|
||||
table.onCell(at("hand:a"), "connect");
|
||||
@@ -309,9 +310,20 @@ def test_연결_단추_칸_메뉴_고르기_창_TS():
|
||||
# 184-3 모든 항목 줄 · 새 줄 연결됨 = 호표 번호 · 자동 줄 연결됨 = 「연결됨」 · 묶음 = 없음
|
||||
assert out["link"] == [None, {"tab": "dg", "key": "DG:a"}] # 단추 칸 = 이동 안 함
|
||||
assert out["cls"] == [
|
||||
"is-lv1 is-title", "is-lv3 is-group is-hand", "is-lv4 is-hand", "is-lv3"] # fmt: skip
|
||||
"is-title",
|
||||
"is-group is-hand",
|
||||
"is-hand",
|
||||
None,
|
||||
] # fmt: skip 201-1 레벨 = 공용 표 level
|
||||
assert out["lv"] == ["name", 1, 3, 4, 3]
|
||||
assert out["edit"] == [True, False] # 묶음 줄 = 이름만
|
||||
picks = ["일위대가 호표", "단가산출 호표", "단가산출 로직(M01 · 새 산근)"]
|
||||
picks = [
|
||||
"일위대가 호표",
|
||||
"단가산출 호표",
|
||||
"서고 구조물조합(마스터 · 회사 · 개인)",
|
||||
"서고 일위대가 사전조합(마스터 · 회사 · 개인)",
|
||||
"단가산출 로직(M01 · 새 산근)",
|
||||
] # 204-2
|
||||
# 누른 칸 아래(left · bottom) · 묶음 줄 = 안 엶 · 연결된 줄(자동 줄도) = 연결 풀기 더함
|
||||
unlink = [10, 40, [*picks, "-", "연결 풀기"]]
|
||||
assert out["menus"] == [unlink, unlink, [10, 40, picks]]
|
||||
|
||||
@@ -125,10 +125,13 @@ def test_메뉴_모든_줄():
|
||||
# 자동 항목 = 더함 · 연결 · 옮김 · 지움(고친 칸 없음 = 자동값으로 없음)
|
||||
assert out["earth:4"] == [*add, "새 공종 추가(1단)", "-", "연결", "연결 풀기", "-",
|
||||
"아래로 ↓", "내어쓰기 ←", "-", "줄 지우기"] # fmt: skip
|
||||
# 204 — 위 형제가 항목이어도 들여쓰기 됨(그 항목 = 저절로 묶음)
|
||||
assert out["earth:5"] == [*add, "새 공종 추가(1단)", "-", "연결", "-", "위로 ↑", "아래로 ↓",
|
||||
"내어쓰기 ←", "-", "자동값으로 되돌리기", "줄 지우기"] # fmt: skip
|
||||
"들여쓰기 →", "내어쓰기 ←", "-", "자동값으로 되돌리기",
|
||||
"줄 지우기"] # fmt: skip
|
||||
assert out["hand:g"] == [*add, "아래에 하위 줄 추가", "새 공종 추가(1단)", "-", "위로 ↑",
|
||||
"아래로 ↓", "내어쓰기 ←", "항목으로 바꾸기", "-", "줄 지우기"] # fmt: skip
|
||||
"아래로 ↓", "들여쓰기 →", "내어쓰기 ←", "항목으로 바꾸기", "-",
|
||||
"줄 지우기"] # fmt: skip
|
||||
assert out["hand:a"] == [*add, "새 공종 추가(1단)", "-", "연결", "연결 풀기", "-", "내어쓰기 ←",
|
||||
"묶음 줄로 바꾸기", "-", "줄 지우기"] # fmt: skip
|
||||
# 조달수수료 · 관급 자재 = 연결 · 옮김 · 지움 없음 · 관급자재대 제목 = 옮김 · 지움 없음
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""185-1 B09 총괄설계내역서 · 공사원가계산서 줄 레벨 — 줄 `level` · 줄 class(182 is-lvN) · 칸 class · CSS.
|
||||
"""185-1 B09 총괄설계내역서 · 공사원가계산서 줄 레벨 — 줄 `level` · 1단 굵게 · 공용 표 level 옵션(201-1).
|
||||
|
||||
레벨 = 화면이 정함(줄 짜임이 `B09_Estimation_UI_Tabs_Total.ts` 에 있음 · 셈 짝은 값만 냄 → 거울 없음).
|
||||
총괄 = 가.~바. 소계 1(굵게) · 그 밖 2 · 원가계산서 = 구분만 1 · 비목 소계 2 · 세목 3.
|
||||
들여쓰기 = 명칭 칸(is-indent) · 앞 칸(is-flat)은 그대로.
|
||||
들여쓰기 = 공용 표 level { key: 명칭 칸 · of = 줄 level }(201-1 · 옛 is-lvN · is-indent · is-flat 갈음).
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -20,7 +20,7 @@ SUMS = {"material": 194234417, "labor": 346800554, "expense": 169324165, "supply
|
||||
BODY = f"""
|
||||
const user = {{ estimate: {{}} }};
|
||||
const ctx = {{ sheets: {{ total: computeTotal({json.dumps(SUMS)}, user.estimate) }}, payload: {{}}, user, setUser: () => {{}} }};
|
||||
const pack = (t) => t.rows.map((r, i) => [r.key, r.level, t.rowClass(i), t.cellClass(i, "name"), t.cellClass(i, t.columns[0].key), t.cellClass(i, "amount")]);
|
||||
const pack = (t) => t.rows.map((r, i) => [r.key, r.level, t.rowClass(i), t.level.key, t.level.of(i), t.cellClass ?? null]);
|
||||
console.log(JSON.stringify({{ total: pack(buildTotal(ctx)), cost: pack(buildCost(ctx)) }}));
|
||||
"""
|
||||
|
||||
@@ -42,39 +42,35 @@ def _run() -> dict:
|
||||
def test_총괄_레벨():
|
||||
rows = {k: (lv, cls) for k, lv, cls, *_ in _run()["total"]}
|
||||
top = ["direct", "net_cost", "da", "supply_price", "contract", "grand"]
|
||||
assert all(rows[k] == (1, "is-lv1 is-title") for k in top)
|
||||
assert all(rows[k] == (1, "is-title") for k in top)
|
||||
for k in ["in_price", "in_env", "indirect_labor", "machine", "retirement", "overhead", "vat",
|
||||
"supply"]: # fmt: skip
|
||||
assert rows[k] == (2, "is-lv2"), k
|
||||
assert rows[k] == (2, None), k
|
||||
assert {lv for lv, _ in rows.values()} == {1, 2}
|
||||
|
||||
|
||||
def test_원가계산서_레벨():
|
||||
rows = {k: (lv, cls) for k, lv, cls, *_ in _run()["cost"]}
|
||||
for k in ["overhead", "profit", "supply_price", "vat", "contract", "supply", "grand"]:
|
||||
assert rows[k] == (1, "is-lv1 is-title"), k
|
||||
assert rows[k] == (1, "is-title"), k
|
||||
for k in ["material", "labor", "expense_sum"]:
|
||||
assert rows[k] == (2, "is-lv2"), k
|
||||
assert rows[k] == (2, None), k
|
||||
for k in ["direct_material", "scrap", "direct_labor", "indirect_labor", "expense", "safety"]:
|
||||
assert rows[k] == (3, "is-lv3"), k
|
||||
assert rows[k] == (3, None), k
|
||||
|
||||
|
||||
def test_칸_class_명칭만_들여씀():
|
||||
def test_명칭_칸만_들여씀_공용_level():
|
||||
out = _run()
|
||||
for tab in ("total", "cost"):
|
||||
for _k, _lv, _cls, name, first, amount in out[tab]:
|
||||
assert (name, first, amount) == ("is-indent", "is-flat", None)
|
||||
for _k, lv, _cls, key, of, cell in out[tab]:
|
||||
assert (key, of, cell) == ("name", lv, None) # 칸 class(is-indent · is-flat) 없음
|
||||
|
||||
|
||||
def test_CSS_레벨_규칙():
|
||||
def test_CSS_옛_레벨_규칙_없음_공용_표로():
|
||||
css = (B09 / "B09_Estimation_UI_Page.css").read_text(encoding="utf-8")
|
||||
assert "is-indent" not in css and "is-flat" not in css and "is-lv" not in css
|
||||
common = (ROOT / "ui_template" / "ui_template_data_table.css").read_text(encoding="utf-8")
|
||||
assert re.search(
|
||||
r'tr\[class\*="is-lv"\] td\.is-flat:first-child \{\s*padding-left: var\(--spacing-8\);', css
|
||||
r"td\.ui-data-table__lv \{\s*padding-left: calc\(var\(--spacing-8\) \+ var\(--dt-lv, 0\) \* 1em\);",
|
||||
common,
|
||||
)
|
||||
for lv, em in ((2, 1), (3, 2)):
|
||||
assert re.search(
|
||||
rf"tr\.is-lv{lv} td\.is-indent \{{\s*padding-left: calc\(var\(--spacing-8\) \+ {em}em\);",
|
||||
css,
|
||||
)
|
||||
# 글자 크기 = 182 규칙 재사용(줄 높이 28px → 16px 까지)
|
||||
assert re.search(r"tr\.is-lv1 td \{\s*font-size: var\(--text-body\);", css)
|
||||
|
||||
@@ -144,9 +144,10 @@ def test_측점_누르면_그_카드로_다시_그려도_연_칩_유지_0이면_
|
||||
"""
|
||||
bar.update(items(["a", "b"], [], []));
|
||||
chip("fill").click();
|
||||
panel.children[1].click();
|
||||
panel.children[1].click(); // 207-1 — 고르면 내용 칸 접힘
|
||||
chipsRow.children[0].click(); chipsRow.children[0].click(); // 칩 누름은 측점 고름 아님
|
||||
out.picks = picks.slice();
|
||||
chip("fill").click();
|
||||
bar.update(items(["a", "b", "x"], [], []));
|
||||
out.kept = view();
|
||||
bar.update(items([], ["p"], []));
|
||||
@@ -165,7 +166,7 @@ out.gone = view();
|
||||
def test_알림_줄은_칩을_쓰고_긴_설명은_호버_짧은_이름은_사전():
|
||||
src = (B06 / "B06_Section_UI_Cross_FillSlope_Notice.ts").read_text(encoding="utf-8")
|
||||
assert "<details" not in src and '"details"' not in src and "summary" not in src
|
||||
assert "createNoticeChips(onPick)" in src
|
||||
assert "createNoticeChips(onPick, selected)" in src # 207-1 고른 측점 넘김
|
||||
for label, hint in (
|
||||
('L("B06_Notice_FillSlope")', "FILL_SLOPE_WARN_TEXT"),
|
||||
('L("B06_Notice_PaveSuggested")', 'L("B06_Design_Paved_Suggested")'),
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
"""200-2 B09 설계내역서 레벨 글 · 레벨 도구 줄 — 글 칸 왼쪽 맞춤 · 레벨 = 공종 · 명칭 칸 padding 들여쓰기 ·
|
||||
글자 크기 한 벌 · 단추 넷(고른 줄 · 못 하면 disabled) · 키 셋(Tab · Shift+Tab · Alt+↑↓) · 편집 중엔 키 안 먹음.
|
||||
|
||||
- 단추 · 키 = 우클릭 메뉴(billMenu) 같은 항목을 부름 — 줄 꼴 · 저장 꼴은 184-3 시험 그대로
|
||||
- 표 · 문서 = 가짜 DOM(줄 tr · 글 칸 input) · 다시 그림 = buildBill + levelBar 다시
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from test_184_3_b09_bill_menu import ROWS, _node, _ts # noqa: E402
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
B09 = ROOT / "B09_Estimation"
|
||||
|
||||
BAR_JS = """
|
||||
class FakeEl {
|
||||
constructor(tag) {
|
||||
this.tagName = tag.toUpperCase(); this.kids = []; this.dataset = {}; this.cls = new Set();
|
||||
this.on = {}; this.parent = null; this.isConnected = true; this.disabled = false; this.textContent = "";
|
||||
}
|
||||
set className(v) { this.cls = new Set(v.split(" ").filter(Boolean)); }
|
||||
get classList() { const s = this.cls; return { add: (c) => s.add(c), remove: (c) => s.delete(c) }; }
|
||||
append(...kids) { for (const k of kids) { k.parent = this; this.kids.push(k); } }
|
||||
addEventListener(type, f) { (this.on[type] ??= []).push(f); }
|
||||
fire(type, ev) { for (const f of this.on[type] ?? []) f(ev); }
|
||||
contains(x) { for (let n = x; n; n = n.parent) if (n === this) return true; return false; }
|
||||
all() { return this.kids.flatMap((k) => [k, ...k.all()]); }
|
||||
closest() { for (let n = this; n; n = n.parent) if (n.tagName === "TR") return n; return null; }
|
||||
querySelectorAll() { return this.all().filter((n) => n.tagName === "TR" && n.cls.has("is-focus")); }
|
||||
querySelector(sel) { const i = sel.match(/data-i="(-?\\d+)"/)[1]; return this.all().find((n) => n.dataset.i === i) ?? null; }
|
||||
focus() { doc.activeElement = this; }
|
||||
blur() { doc.activeElement = doc.body; }
|
||||
}
|
||||
const doc = new FakeEl("#document");
|
||||
doc.body = new FakeEl("body");
|
||||
doc.activeElement = doc.body;
|
||||
doc.createElement = (t) => new FakeEl(t);
|
||||
globalThis.document = doc;
|
||||
const hovers = [];
|
||||
function createButton(o) {
|
||||
const b = new FakeEl("button");
|
||||
b.label = o.label; b.variant = o.variant;
|
||||
b.addEventListener("click", o.onClick);
|
||||
return b;
|
||||
}
|
||||
function attachHoverInfo(el, text) { hovers.push([el.textContent, text]); }
|
||||
|
||||
let user = {};
|
||||
let table, root, bar;
|
||||
const draw = () => {
|
||||
table = buildBill({ sheets: { bill: { rows: __ROWS__, totals: null } }, payload: {},
|
||||
get user() { return user; }, setUser: (u) => (user = u) });
|
||||
if (root) root.isConnected = false;
|
||||
if (root?.contains(doc.activeElement)) doc.activeElement = doc.body; // 지운 요소 초점 = body
|
||||
root = new FakeEl("div");
|
||||
table.rows.forEach((r, i) => {
|
||||
const tr = new FakeEl("tr"); tr.dataset.i = String(i);
|
||||
const td = new FakeEl("td"); const input = new FakeEl("input");
|
||||
td.append(input); tr.append(td); root.append(tr);
|
||||
});
|
||||
bar = levelBar(root, table);
|
||||
};
|
||||
const at = (key) => table.rows.findIndex((r) => r.key === key);
|
||||
const tr = (key) => root.kids[at(key)];
|
||||
const pick = (key) => root.fire("click", { target: tr(key).kids[0] });
|
||||
const buttons = () => bar.kids.filter((k) => k.tagName === "BUTTON");
|
||||
const off = () => buttons().map((b) => b.disabled);
|
||||
const order = () => (user.bill?.rows ?? []).map((e) => [e.key, e.parent ?? null]);
|
||||
const key = (k, more = {}) => {
|
||||
let stopped = false;
|
||||
doc.fire("keydown", { key: k, shiftKey: false, altKey: false, ...more, preventDefault: () => (stopped = true) });
|
||||
return stopped;
|
||||
};
|
||||
const out = {};
|
||||
draw();
|
||||
out.labels = buttons().map((b) => [b.label, b.dataset.b09Level, b.variant]);
|
||||
out.guide = bar.kids.at(-1).textContent;
|
||||
out.hover = hovers.length;
|
||||
out.none = off();
|
||||
pick("earth:4");
|
||||
out.earth4 = off();
|
||||
out.focus = [tr("earth:4").cls.has("is-focus"), pickedClass(table.rows[at("earth:4")]),
|
||||
pickedClass(table.rows[at("earth:5")])];
|
||||
buttons()[3].fire("click");
|
||||
out.down = order();
|
||||
user = {}; draw();
|
||||
out.kept = [off(), tr("earth:4").cls.has("is-focus"), pickedClass(table.rows[at("earth:4")])];
|
||||
root.focus();
|
||||
out.tabStop = key("Tab");
|
||||
out.tabNoop = order();
|
||||
out.shiftTab = [key("Tab", { shiftKey: true }), order()];
|
||||
user = {}; draw();
|
||||
pick("earth:5");
|
||||
root.focus();
|
||||
out.altUp = [key("ArrowUp", { altKey: true }), order()];
|
||||
user = {}; draw(); pick("earth:4");
|
||||
out.altDown = [key("ArrowDown", { altKey: true }), order()];
|
||||
user = {}; draw(); pick("earth:4");
|
||||
doc.activeElement = tr("earth:4").kids[0].kids[0];
|
||||
out.editing = [key("Tab", { shiftKey: true }), key("ArrowDown", { altKey: true }), order()];
|
||||
out.esc = [key("Escape"), doc.activeElement === root];
|
||||
out.afterEsc = [key("Tab", { shiftKey: true }), order().length > 0];
|
||||
user = {}; draw(); pick("earth:4");
|
||||
doc.activeElement = new FakeEl("input");
|
||||
out.outside = [key("Tab", { shiftKey: true }), order()];
|
||||
root.focus();
|
||||
key("Escape");
|
||||
out.unpick = [off(), tr("earth:4").cls.has("is-focus"), key("Tab")];
|
||||
out.keyAct = [
|
||||
keyAct({ key: "Tab", shiftKey: false, altKey: false }, false),
|
||||
keyAct({ key: "Tab", shiftKey: true, altKey: false }, false),
|
||||
keyAct({ key: "ArrowUp", shiftKey: false, altKey: true }, false),
|
||||
keyAct({ key: "ArrowDown", shiftKey: false, altKey: true }, false),
|
||||
keyAct({ key: "Tab", shiftKey: false, altKey: false }, true),
|
||||
keyAct({ key: "ArrowUp", shiftKey: false, altKey: false }, false),
|
||||
keyAct({ key: "Tab", shiftKey: false, altKey: false, ctrlKey: true }, false),
|
||||
];
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill", "UI_Tabs_Bill_Drag",
|
||||
"UI_Tabs_Bill_Level"] # fmt: skip
|
||||
IN, OUT, UP, DOWN = "들여쓰기 →", "내어쓰기 ←", "위로 ↑", "아래로 ↓"
|
||||
|
||||
|
||||
def _bar():
|
||||
body = BAR_JS.replace("__ROWS__", json.dumps(ROWS, ensure_ascii=False))
|
||||
return json.loads(_node(_ts(SRC) + body))
|
||||
|
||||
|
||||
def _keys(order):
|
||||
return [k for k, _ in order]
|
||||
|
||||
|
||||
def test_단추_넷_고른_줄_못_하면_흐림():
|
||||
out = _bar()
|
||||
assert out["labels"] == [["→ 들여쓰기", IN, "ghost"], ["← 내어쓰기", OUT, "ghost"],
|
||||
["↑ 위로", UP, "ghost"], ["↓ 아래로", DOWN, "ghost"]] # fmt: skip
|
||||
assert "Tab" in out["guide"] and "Shift+Tab" in out["guide"] and "Alt+↑↓" in out["guide"]
|
||||
assert out["hover"] == 1 # 안내 한 줄 = 공용 호버로 자세히
|
||||
assert out["none"] == [True] * 4 # 고른 줄 없음 = 모두 흐림
|
||||
# earth:4 = 1.1 첫 줄 — 들여(위 형제 없음) · 위로 = 못 함 · 내어 · 아래로 = 됨(184-3 메뉴와 같음)
|
||||
assert out["earth4"] == [True, False, True, False]
|
||||
assert out["focus"] == [True, "is-focus", None]
|
||||
# [↓ 아래로] = 메뉴 「아래로 ↓」 와 같은 결과
|
||||
keys = _keys(out["down"])
|
||||
assert keys.index("earth:5") + 1 == keys.index("earth:4")
|
||||
# 다시 그려도 고른 줄 · 단추 상태 남음
|
||||
assert out["kept"] == [[True, False, True, False], True, "is-focus"]
|
||||
|
||||
|
||||
def test_키_셋_편집_중엔_안_먹음():
|
||||
out = _bar()
|
||||
assert out["tabStop"] is True and out["tabNoop"] == [] # 못 하는 들여쓰기 = 바뀜 없음
|
||||
stopped, order = out["shiftTab"]
|
||||
assert stopped and ("earth:4", "sec:1") in [tuple(x) for x in order] # 내어쓰기
|
||||
for name in ("altUp", "altDown"):
|
||||
stopped, order = out[name]
|
||||
keys = _keys(order)
|
||||
assert stopped and keys.index("earth:5") + 1 == keys.index("earth:4"), name
|
||||
# 글 칸 편집 중 = 키 안 먹음(기본 동작 그대로) · Esc = 편집 끝(표에 초점) → 키 먹음
|
||||
assert out["editing"] == [False, False, []]
|
||||
assert out["esc"] == [False, True]
|
||||
assert out["afterEsc"] == [True, True]
|
||||
# 초점이 표 · 도구 줄 밖 = 안 먹음 · Esc(편집 아님) = 고름 풂 → 흐림 · Tab 은 기본 동작
|
||||
assert out["outside"] == [False, []]
|
||||
assert out["unpick"] == [[True] * 4, False, False]
|
||||
# 그냥 ↑ = 옮기기(204 방향키 · test_204_1)
|
||||
assert out["keyAct"] == [IN, OUT, UP, DOWN, None, UP, None]
|
||||
|
||||
|
||||
def test_왼쪽_맞춤_레벨_들여쓰기_글자_크기_한_벌():
|
||||
css = (B09 / "B09_Estimation_UI_Page.css").read_text("utf-8")
|
||||
bill = '.b09-estimation [data-b09-table="bill"]'
|
||||
# 201-1 레벨 들여쓰기 = 공용 표 level 옵션(test_201_1) · 레벨별 글자 크기 규칙 없음(한 벌)
|
||||
assert "is-lv" not in css and "font-size: 13px" not in css
|
||||
src = (B09 / "B09_Estimation_UI_Tabs_Bill.ts").read_text("utf-8")
|
||||
assert 'level: { key: "name", of: (i) => rows[i]?.level' in src
|
||||
# 글 칸 왼쪽 맞춤(수량만 오른쪽)
|
||||
assert re.search(
|
||||
re.escape(f'{bill} .ui-data-table__input:not([data-k="qty"]) {{') + r"\s*text-align: left;",
|
||||
css,
|
||||
)
|
||||
assert re.search(r"\.b09-estimation__level \{\s*display: flex;", css)
|
||||
|
||||
|
||||
def test_셸_배선():
|
||||
src = (B09 / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
|
||||
assert "makeTable(bill ? (i) => pickedClass(bill.rows[i]) : undefined)" in src
|
||||
assert "...(level ? [level] : []), table.root" in src
|
||||
lvl = (B09 / "B09_Estimation_UI_Tabs_Bill_Level.ts").read_text("utf-8")
|
||||
assert "table.menu(index)" in lvl # 단추 · 키 = 우클릭 메뉴 같은 함수
|
||||
@@ -0,0 +1,135 @@
|
||||
"""201-1 공용 표 level(보기 전용) · mergeSame(같은 값 세로 병합) 옵션 + B08 · B09 적용 표.
|
||||
|
||||
- level { key, of } = 그 열 칸 class ui-data-table__lv · 줄 style --dt-lv(레벨 − 1) · 글 상자 칸도 · 다루기 단추 없음
|
||||
- mergeSame [열] = 위아래 같은 글이 이어지면 rowspan · 앞 병합 열이 갈리면 갈림 · 빈 값 · 빈칸 글 · 고칠 칸 안 합침 ·
|
||||
합계 줄 안 합침 · 그린 구간(win) 안에서만 · 병합 표 칸 = data-c(열 번호)
|
||||
- 옵션 없으면 무변(rowspan · data-c · --dt-lv 없음)
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
JS = r"""
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(process.argv[1], "config", "node_modules", "typescript"));
|
||||
const file = path.join(process.argv[1], "ui_template", "ui_template_data_table.ts");
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, (p) => (p.endsWith(".css") ? {} : require(p)));
|
||||
const m = box.exports;
|
||||
const body = (html) => html.split("<tbody>")[1].split("</tbody>")[0].split("</tr>").filter(Boolean);
|
||||
const columns = [
|
||||
{ key: "g", label: "구분" }, { key: "s", label: "규격" }, { key: "n", label: "명칭" },
|
||||
{ key: "v", label: "값", kind: "number" },
|
||||
];
|
||||
const rows = [
|
||||
{ g: "흙깍기", s: "가", n: "a", v: 1 },
|
||||
{ g: "흙깍기", s: "가", n: "b", v: 1 },
|
||||
{ g: "흙깍기", s: "나", n: "c", v: 1 },
|
||||
{ g: "운반", s: "나", n: "d", v: 2 },
|
||||
{ g: "운반", s: " ", n: "e", v: 2 },
|
||||
{ g: "", s: " ", n: "f", v: 2 },
|
||||
{ g: "", s: "다", n: "g", v: 3 },
|
||||
];
|
||||
const total = { g: "계", v: 12 };
|
||||
const out = {};
|
||||
out.plain = m.renderDataTableHtml({ columns, rows, total });
|
||||
const merged = { columns, rows, total, mergeSame: ["g", "s"] };
|
||||
out.merged = body(m.renderDataTableHtml(merged));
|
||||
out.mergedFoot = m.renderDataTableHtml(merged).split("<tfoot>")[1];
|
||||
out.spans = m.mergeSpans(merged, 0, rows.length);
|
||||
out.win = m.mergeSpans(merged, 1, 4);
|
||||
out.edit = m.mergeSpans({ ...merged, editable: (i, k) => i === 1 && k === "g" }, 0, 3);
|
||||
out.level = body(m.renderDataTableHtml({
|
||||
columns, rows: rows.slice(0, 3), level: { key: "n", of: (i) => [1, 2, null][i] },
|
||||
editable: (i, k) => i === 1 && k === "n",
|
||||
}));
|
||||
out.cell = [m.cellColumn({ dataset: { c: "3" }, cellIndex: 1 }), m.cellColumn({ dataset: {}, cellIndex: 1 })];
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
def _run() -> dict:
|
||||
result = subprocess.run(
|
||||
["node", "-e", JS, str(ROOT)], capture_output=True, text=True, encoding="utf-8", timeout=60
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_옵션_없으면_무변():
|
||||
html = _run()["plain"]
|
||||
assert "rowspan" not in html.split("<tbody>")[1]
|
||||
assert "data-c=" not in html and "--dt-lv" not in html and "ui-data-table__lv" not in html
|
||||
|
||||
|
||||
def test_mergeSame_같은_값만_rowspan():
|
||||
out = _run()
|
||||
# 구분: 흙깍기 3 · 운반 2 · 빈 값 2(안 합침) / 규격: 가 2(흙깍기 안) · 나 = 구분이 갈려 따로 · 빈칸 글 안 합침
|
||||
assert [s[0] for s in out["spans"]] == [3, 0, 0, 2, 0, 1, 1]
|
||||
assert [s[1] for s in out["spans"]] == [2, 0, 1, 1, 1, 1, 1]
|
||||
assert all(s[2] == 1 and s[3] == 1 for s in out["spans"]) # 지정 안 한 열 = 그대로
|
||||
rows = out["merged"]
|
||||
assert '<td rowspan="3" data-c="0">흙깍기</td>' in rows[0].replace(' class="is-merged"', "")
|
||||
assert 'class="is-merged"' in rows[0]
|
||||
assert (
|
||||
rows[1].count("<td") == 2 and '<td data-c="2">b</td>' in rows[1]
|
||||
) # 합쳐진 칸 빠짐 · 열 번호
|
||||
assert '<td data-num="" data-c="3">2</td>' in rows[4] # 숫자 값 그대로
|
||||
assert "rowspan" not in out["mergedFoot"] # 합계 줄 안 합침
|
||||
# 그린 구간(1~3줄) 안에서만 · 고칠 칸 안 합침
|
||||
assert out["win"] == [[2, 1, 1, 1], [0, 1, 1, 1], [1, 1, 1, 1]]
|
||||
assert [s[0] for s in out["edit"]] == [1, 1, 1]
|
||||
assert out["cell"] == [3, 1]
|
||||
|
||||
|
||||
def test_level_보기_전용_들여쓰기():
|
||||
rows = _run()["level"]
|
||||
assert [re.search(r'style="--dt-lv:(\d)"', r).group(1) for r in rows] == ["0", "1", "0"]
|
||||
assert '<td class="ui-data-table__lv">a</td>' in rows[0]
|
||||
assert rows[0].count("ui-data-table__lv") == 1 # 지정 열만
|
||||
assert '<td class="is-edit ui-data-table__lv" data-num="">' in rows[1] # 글 상자 칸도
|
||||
assert "button" not in "".join(rows) # 다루기 단추 없음(설계내역서만)
|
||||
css = (ROOT / "ui_template" / "ui_template_data_table.css").read_text("utf-8")
|
||||
pad = r"padding-left: calc\(var\(--spacing-8\) \+ var\(--dt-lv, 0\) \* 1em\);"
|
||||
assert re.search(r"td\.ui-data-table__lv \{\s*" + pad + r"\s*text-align: left;", css)
|
||||
assert re.search(r"td\.ui-data-table__lv \.ui-data-table__input \{\s*" + pad, css)
|
||||
assert re.search(r"td\.is-merged \{\s*vertical-align: top;", css)
|
||||
|
||||
|
||||
#: 적용 표 — [파일, 들어 있어야 할 글]
|
||||
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"),
|
||||
("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"'),
|
||||
("B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts", 'level: blockLevel("text"'),
|
||||
("B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts", 'mergeSame: ["kind"]'),
|
||||
("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", 'level: { key: "name"'),
|
||||
("B09_Estimation/B09_Estimation_UI_Shell.ts", "mergeSame: spec.mergeSame"),
|
||||
]
|
||||
|
||||
|
||||
def test_B08_B09_적용():
|
||||
for path, text in APPLIED:
|
||||
assert text in (ROOT / path).read_text("utf-8"), (path, text)
|
||||
# 옛 글 빈칸 지우기 · 앞 빈칸 들여쓰기는 없앰(병합 · level 로 갈음)
|
||||
mat = (ROOT / "B08_Quantity/B08_Quantity_UI_Material.ts").read_text("utf-8")
|
||||
assert '=== row.name ? ""' not in mat and '=== row.group ? ""' not in mat
|
||||
labor = (ROOT / "B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts").read_text("utf-8")
|
||||
assert 'const CHILD = "ㄴ "' in labor and '" 합 계"' not in labor
|
||||
# 셸 칸 → 열 = cellColumn(병합 표에서 cellIndex 가 밀림)
|
||||
shell = (ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts").read_text("utf-8")
|
||||
assert ".cellIndex" not in shell and shell.count("cellColumn(") == 2
|
||||
@@ -104,7 +104,10 @@ def test_묶음_탭_단추가_보조_줄에_안내는_아래에():
|
||||
|
||||
def test_잇기_쉘_안내_줄_아래_도구_비움():
|
||||
shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
|
||||
assert "panel.replaceChildren(table.root, ...(hint ? [hint] : []))" in shell
|
||||
assert (
|
||||
"panel.replaceChildren(...(level ? [level] : []), table.root, ...(hint ? [hint] : []))"
|
||||
in shell
|
||||
) # 200-2 레벨 줄 · 203-1 연결 점검 탭 없앰과 합침
|
||||
assert "...(hint ? [hint] : []), table.root" not in shell # 위 자리 없음
|
||||
assert re.search(r"bars\.draw\(tab\);\s*bars\.setTools\(\[\]\);", shell)
|
||||
assert "setTools: bars.setTools" in shell
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""203-1 B09 좌측 「데이터 점검」 묶음(사용자 60번).
|
||||
|
||||
- 묶음 이름 = 데이터 점검 · 안 설명 글 없음(굳힌 시각 · 까닭 = [업데이트] 공용 호버)
|
||||
- [단가 갱신] → [업데이트] · 낡으면(서버 `book_stale` = 지금 M01 로 굳힌 책과 다름) 점 배지(is-pending)
|
||||
- 「연결 점검」 탭 내용 = 묶음 안 목록(누르면 그 탭 그 줄) · 위 탭 줄에서 뺌 · 첫 탭 = 공사원가계산서
|
||||
"""
|
||||
|
||||
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 B09_Estimation import B09_Estimation_Prices as prices # noqa: E402
|
||||
from resources.tester.helper_b09_group_cards import TABS, run # noqa: E402
|
||||
|
||||
CHECK = "B09_Estimation/B09_Estimation_UI_Check.ts"
|
||||
SHELL = (ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts").read_text("utf-8")
|
||||
|
||||
|
||||
def test_묶음_이름_단추_글_설명_없음():
|
||||
assert 'heading.textContent = "데이터 점검";' in SHELL and "원가 단가" not in SHELL
|
||||
assert 'label: "업데이트",' in SHELL and "단가 갱신" not in SHELL
|
||||
# 옛 굳힌 시각 안내 글(made) 없음 · 묶음 = 제목 · [업데이트] · 연결 점검
|
||||
assert "made.textContent" not in SHELL and "b09-estimation__hint" not in SHELL
|
||||
assert "section.append(heading, refresh, check.list);" in SHELL
|
||||
assert "check.draw(payload, sheets);" in SHELL
|
||||
|
||||
|
||||
def test_연결_점검_탭_없음_첫_탭_공사원가계산서():
|
||||
out = run(
|
||||
[TABS],
|
||||
"\nconsole.log(JSON.stringify({ ids: TABS.map(([t]) => t), names: TABS.map(([, n]) => n),"
|
||||
" groups: TAB_GROUPS.map(([n]) => n), first: TABS[0][0] }));",
|
||||
)
|
||||
assert "check" not in out["ids"] and "연결 점검" not in out["names"] + out["groups"]
|
||||
assert out["first"] == "cost" and out["groups"][0] == "공사원가계산서"
|
||||
assert "let current: TabId = TABS[0][0];" in SHELL
|
||||
assert "buildCheck" not in SHELL and "check:" not in SHELL
|
||||
|
||||
|
||||
def test_좌측_목록_이동_호버_배지():
|
||||
out = run(
|
||||
[TABS, CHECK],
|
||||
"""
|
||||
const moves = [];
|
||||
const refresh = document.createElement("button");
|
||||
const panel = createCheckPanel(refresh, (link) => moves.push(link));
|
||||
const sheets = { gaps: [
|
||||
{ tab: "dg", key: "DG1", name: "찰쌓기", why: "대가 원천 없음" },
|
||||
{ tab: "material", key: "MT9", name: "모래", why: "단가 없음" },
|
||||
] };
|
||||
const payload = { book: { made: "2026-10-01T09:00:00" }, stale: "M01 에서 바뀐 단가 2",
|
||||
notes: ["B08 자재 집계표 못 읽음"], inputs: { missing: ["EQ1", "LB2"] } };
|
||||
panel.draw(payload, sheets);
|
||||
const view = () => ({
|
||||
pending: refresh.classList.contains("is-pending"),
|
||||
texts: panel.list.children.map((c) => c.text),
|
||||
rows: (panel.list.children[1]?.children ?? []).map((r) => [r.dataset.i, r.className, r.text]),
|
||||
});
|
||||
const a = view();
|
||||
const rowsBox = panel.list.children[1];
|
||||
rowsBox.children[1].click();
|
||||
const tipRefresh = hoverCalls.find((h) => h.root === refresh).text();
|
||||
const rowHover = hoverCalls.find((h) => h.root === panel.list);
|
||||
const tipRow = rowHover.text(rowsBox.children[0]);
|
||||
panel.draw({ book: null, stale: null }, { gaps: [] });
|
||||
const b = view();
|
||||
const tipEmpty = refreshTip({ book: null, stale: "단가 아직 안 굳힘" });
|
||||
console.log(JSON.stringify({ a, b, moves, tipRefresh, tipRow, selector: rowHover.selector, tipEmpty }));
|
||||
""",
|
||||
)
|
||||
a, b = out["a"], out["b"]
|
||||
assert a["pending"] and not b["pending"]
|
||||
assert a["texts"][0] == "연결 없음 2곳"
|
||||
assert a["rows"] == [
|
||||
["0", "ui-sidebar-row", "찰쌓기일위대가표"],
|
||||
["1", "ui-sidebar-row", "모래재료비목록표"],
|
||||
]
|
||||
assert a["texts"][2:] == ["B08 자재 집계표 못 읽음", "M01 에 없는 키 2 — EQ1 · LB2"]
|
||||
assert out["moves"] == [{"tab": "material", "key": "MT9"}]
|
||||
assert out["tipRow"] == "대가 원천 없음" and out["selector"] == "[data-i]"
|
||||
assert "단가 굳힘 2026-10-01 09:00:00" in out["tipRefresh"]
|
||||
assert "업데이트 필요 — M01 에서 바뀐 단가 2" in out["tipRefresh"]
|
||||
assert b["texts"] == ["연결 없음 0곳"] and b["rows"] == []
|
||||
assert out["tipEmpty"].startswith("단가 아직 안 굳힘") and "업데이트 필요" in out["tipEmpty"]
|
||||
|
||||
|
||||
def test_배지_조건_서버_book_stale():
|
||||
keys = ["EQ000004"] # 불도저 · 조종원 · 경유(test_144 와 같은 견본)
|
||||
book = json.loads(json.dumps(prices.refresh_book(keys), ensure_ascii=False))
|
||||
assert prices.book_stale(book, keys) is None # 방금 굳힌 책 = 업데이트 필요 없음
|
||||
assert prices.book_stale(None, keys) == "단가 아직 안 굳힘"
|
||||
first, *rest = list(book["rows"])
|
||||
old = {**book, "rows": {k: book["rows"][k] for k in rest}, "fx": []}
|
||||
old["rows"][rest[0]] = {**old["rows"][rest[0]], "price": -1}
|
||||
old["driver_factor"] = {"num": 1, "den": 1, "text": "1/1"}
|
||||
assert prices.book_stale(old, keys) == (
|
||||
"새 단가 줄 1 · M01 에서 바뀐 단가 1 · 운전사 계수 바뀜 · 환율 바뀜"
|
||||
)
|
||||
router = (ROOT / "B09_Estimation/B09_Estimation_Router.py").read_text("utf-8")
|
||||
assert '"stale": await asyncio.to_thread(prices.book_stale, book, keys),' in router
|
||||
|
||||
|
||||
def test_배지_꼴_경고_색_토큰():
|
||||
css = (ROOT / "B09_Estimation/B09_Estimation_UI_Page.css").read_text("utf-8")
|
||||
block = css[css.index(".b09-options .ui-btn.is-pending::after") :]
|
||||
block = block[: block.index("}")]
|
||||
assert "position: absolute" in block and "border-radius: 50%" in block
|
||||
assert "background: var(--color-warning)" in block
|
||||
@@ -0,0 +1,242 @@
|
||||
"""204-1 B09 설계내역서 — 방향키 · 끌기 레벨 · 이동 · 자손 함께 · 묶음 자동 · 합 전달.
|
||||
|
||||
- 방향키 = 고른 줄(편집 중 아님) → 들여 · ← 내어 · ↑↓ 옮김(200-2 메뉴 항목 그대로) · Alt+←→ 안 받음
|
||||
- 끌기 자리 = `dropAt`(틈 · 바라는 레벨 → 부모 · 앞 줄) ·
|
||||
놓기 = 표 drop(`dropMove` · 메뉴와 같은 지킬 것)
|
||||
- 묶음 자동 = 셈(짝 `Calc_Bill` · 거울) — 자식이 생긴 항목 = 묶음(자손 합 · 위로 전달) ·
|
||||
자식이 빠지면 다시 항목(제 수량 · 단가) · 자식이 다 빠진 새 묶음 = 항목(화면 · 맨 위 공종 빼고)
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from test_184_2_b09_bill_free import ( # noqa: E402
|
||||
BASE,
|
||||
BOOKS,
|
||||
DG,
|
||||
INPUTS,
|
||||
_calc,
|
||||
_freeze,
|
||||
_put,
|
||||
_rows,
|
||||
)
|
||||
from test_184_2_b09_bill_free import _node as _node_calc # noqa: E402
|
||||
from test_184_3_b09_bill_menu import ROWS, _node, _row, _ts # noqa: E402
|
||||
from test_200_2_b09_bill_level_bar import BAR_JS # noqa: E402
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B09_Estimation import B09_Estimation_Store as store # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: E402
|
||||
|
||||
SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill", "UI_Tabs_Bill_Drag",
|
||||
"UI_Tabs_Bill_Level"] # fmt: skip
|
||||
|
||||
|
||||
def _order(bill):
|
||||
return [(e["key"], e.get("parent"), e.get("kind")) for e in (bill or {}).get("rows", [])]
|
||||
|
||||
|
||||
# --- 방향키 -----------------------------------------------------------------------------
|
||||
|
||||
KEYS_JS = """
|
||||
const out = {};
|
||||
const press = (pickKey, k, more = {}) => {
|
||||
user = {}; draw(); pick(pickKey); root.focus();
|
||||
return [key(k, more), order()];
|
||||
};
|
||||
out.right = press("earth:5", "ArrowRight");
|
||||
out.left = press("earth:4", "ArrowLeft");
|
||||
out.up = press("earth:5", "ArrowUp");
|
||||
out.down = press("earth:4", "ArrowDown");
|
||||
out.altLeft = press("earth:4", "ArrowLeft", { altKey: true });
|
||||
user = {}; draw(); pick("earth:5");
|
||||
doc.activeElement = tr("earth:5").kids[0].kids[0];
|
||||
out.editing = [key("ArrowRight"), key("ArrowUp"), order()];
|
||||
out.guide = bar.kids.at(-1).textContent;
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
def test_방향키_넷_편집_중엔_안_먹음():
|
||||
head = BAR_JS[: BAR_JS.index("const out = {};")]
|
||||
body = head.replace("__ROWS__", json.dumps(ROWS, ensure_ascii=False)) + KEYS_JS
|
||||
out = json.loads(_node(_ts(SRC) + body))
|
||||
stopped, order = out["right"] # → = 위 항목 아래로(earth:4 = 저절로 묶음)
|
||||
assert stopped and ["earth:5", "earth:4"] in order
|
||||
stopped, order = out["left"] # ← = 1.1 밖으로(1 아래)
|
||||
assert stopped and ["earth:4", "sec:1"] in order
|
||||
for name in ("up", "down"): # ↑↓ = 같은 부모 안 자리 바꿈
|
||||
stopped, order = out[name]
|
||||
keys = [k for k, _ in order]
|
||||
assert stopped and keys.index("earth:5") + 1 == keys.index("earth:4"), name
|
||||
assert out["altLeft"] == [False, []] # Alt+← = 브라우저 뒤로 — 안 받음
|
||||
assert out["editing"] == [False, False, []] # 글 칸 편집 중 = 글 칸 커서
|
||||
assert "←→" in out["guide"] and "↑↓" in out["guide"] and "끌기" in out["guide"]
|
||||
|
||||
|
||||
# --- 끌기 자리 · 놓기 ------------------------------------------------------------------------
|
||||
|
||||
DROP_JS = """
|
||||
const start = __USER__;
|
||||
let user = start;
|
||||
const ctx = { sheets: { bill: { rows: __ROWS__, totals: null } }, payload: {},
|
||||
get user() { return user; }, setUser: (u) => (user = u) };
|
||||
const table = buildBill(ctx);
|
||||
const at = (k) => table.rows.findIndex((r) => r.key === k);
|
||||
const order = (b) => (b?.rows ?? []).map((e) => [e.key, e.parent ?? null, e.kind ?? null]);
|
||||
const out = { at: {}, drop: {} };
|
||||
for (const [name, k, gap, want] of __ATS__) out.at[name] = dropAt(table.rows, at(k), gap, want);
|
||||
for (const [name, k, parent, before] of __DROPS__) {
|
||||
user = start;
|
||||
const run = table.drop(at(k), parent, before);
|
||||
if (run) run();
|
||||
out.drop[name] = run ? order(user.bill) : null;
|
||||
}
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
#: ROWS + 맨 위 새 공종(hand:n · 자식 hand:k) — 4 자재대 뒤
|
||||
ROWS2 = [*ROWS[:9], _row("hand:n", None, 1, "group", no="5"), _row("hand:k", "hand:n", 2),
|
||||
*ROWS[9:]] # fmt: skip
|
||||
|
||||
|
||||
def _drag(ats=(), drops=(), rows=ROWS2, user=None):
|
||||
body = (
|
||||
DROP_JS.replace("__ROWS__", json.dumps(rows, ensure_ascii=False))
|
||||
.replace("__USER__", json.dumps(user or {}, ensure_ascii=False))
|
||||
.replace("__ATS__", json.dumps([list(a) for a in ats], ensure_ascii=False))
|
||||
.replace("__DROPS__", json.dumps([list(d) for d in drops], ensure_ascii=False))
|
||||
)
|
||||
return json.loads(_node(_ts(SRC) + body))
|
||||
|
||||
|
||||
def test_끌기_자리_레벨():
|
||||
# 차례 0 sec:1 · 1 sec:1.2 · 2 earth:4 · 3 earth:5 · 4 hand:g · 5 hand:a · 6 hand:b · 7 sec:4 ·
|
||||
# 8 mat:M1 · 9 hand:n · 10 hand:k · 11 sec:G · 12 mat:M2 · 13 fee
|
||||
out = _drag(
|
||||
ats=[
|
||||
("오른쪽", "earth:5", 3, 4), # 제자리 + 한 칸 오른쪽 = 위 항목 아래로
|
||||
("제자리", "earth:5", 3, 3),
|
||||
("제자리끝", "earth:5", 4, 3),
|
||||
("자손안", "hand:g", 5, 3),
|
||||
("위로", "hand:g", 2, 3),
|
||||
("멀리오른쪽", "hand:g", 2, 9), # 레벨 = 위 줄 + 1 까지
|
||||
("자재아래", "earth:5", 9, 3), # 자재 줄 = 자식 못 둠 → 형제로
|
||||
("맨위안됨", "earth:5", 7, 1), # 항목 = 맨 위(1) 못 감 → 2
|
||||
("맨위공종", "sec:4", 0, 1),
|
||||
]
|
||||
)["at"]
|
||||
assert out["오른쪽"] == {"parent": "earth:4", "before": None, "level": 4}
|
||||
assert out["제자리"] is None and out["제자리끝"] is None and out["자손안"] is None
|
||||
assert out["위로"] == {"parent": "sec:1.2", "before": "earth:4", "level": 3}
|
||||
assert out["멀리오른쪽"] == out["위로"]
|
||||
assert out["자재아래"] == {"parent": "sec:4", "before": None, "level": 2}
|
||||
assert out["맨위안됨"] == {"parent": "sec:1", "before": None, "level": 2}
|
||||
assert out["맨위공종"] == {"parent": None, "before": "sec:1", "level": 1}
|
||||
|
||||
|
||||
def test_놓기_자손_함께_묶음_자동_막음():
|
||||
out = _drag(
|
||||
drops=[
|
||||
("자손", "hand:g", "sec:1.2", "earth:4"),
|
||||
("항목아래", "earth:5", "earth:4", None),
|
||||
("하나빠짐", "hand:a", "sec:1.2", "hand:b"),
|
||||
("맨위남음", "hand:k", "sec:4", None),
|
||||
("관급밖", "mat:M2", "sec:1", None),
|
||||
("수수료", "fee", "sec:1", None),
|
||||
("관급제목", "sec:G", None, "sec:1"),
|
||||
("자재아래", "earth:5", "mat:M1", None),
|
||||
("자기자손", "hand:g", "hand:a", None),
|
||||
]
|
||||
)["drop"]
|
||||
keys = [k for k, *_ in out["자손"]]
|
||||
# 묶음 통째 — 자식 hand:a 는 부모 키 그대로 · 차례 = hand:g 바로 뒤
|
||||
assert keys[keys.index("hand:g") + 1] == "hand:a" and keys.index("hand:g") < keys.index(
|
||||
"earth:4"
|
||||
)
|
||||
assert ["hand:a", "hand:g", None] in out["자손"]
|
||||
assert ["earth:5", "earth:4", None] in out["항목아래"] # 저장 = 부모만(묶음 = 셈이 정함)
|
||||
# 자식이 다 빠진 새 묶음 = 항목(kind 지움) · 맨 위 공종 = 묶음 그대로
|
||||
assert ["hand:g", "sec:1.2", None] in out["하나빠짐"]
|
||||
assert ["hand:n", None, "group"] in out["맨위남음"]
|
||||
for name in ("관급밖", "수수료", "관급제목", "자재아래", "자기자손"):
|
||||
assert out[name] is None, name
|
||||
|
||||
|
||||
def test_깊이_5_넘는_놓기_막음():
|
||||
deep = [
|
||||
_row("sec:1", None, 1, "title"), _row("sec:1.2", "sec:1", 2, "title"),
|
||||
_row("hand:g", "sec:1.2", 3, "group"), _row("hand:g2", "hand:g", 4, "group"),
|
||||
_row("hand:g3", "hand:g2", 5, "group"), _row("hand:z", "hand:g2", 5),
|
||||
_row("hand:y", "sec:1.2", 3),
|
||||
] # fmt: skip
|
||||
out = _drag(drops=[("깊음", "hand:g", "hand:y", None), ("됨", "hand:z", "hand:y", None)],
|
||||
rows=deep)["drop"] # fmt: skip
|
||||
assert out["깊음"] is None
|
||||
assert ["hand:z", "hand:y", None] in out["됨"]
|
||||
|
||||
|
||||
# --- 셈 — 묶음 ↔ 항목 자동 · 합 전달 · 거울 -----------------------------------------------------
|
||||
|
||||
HANDS = [
|
||||
{"key": "hand:a", "parent": "sec:3", "name": "a", "unit": "㎡", "qty": 2, "ref": DG},
|
||||
{"key": "hand:c", "parent": "hand:a", "name": "c", "unit": "㎡", "qty": 1, "ref": DG},
|
||||
]
|
||||
|
||||
|
||||
def _bills():
|
||||
frozen = _freeze(_calc())
|
||||
under = _put(frozen, "earth:1", parent="earth:4") # 1.1 규준틀 → 절토(토사) 아래
|
||||
hands = {**frozen, "rows": frozen["rows"] + HANDS}
|
||||
alone = {**frozen, "rows": frozen["rows"] + HANDS[:1]}
|
||||
mat = _put(frozen, "earth:5", parent="mat:b08:sand")
|
||||
return frozen, under, hands, alone, mat
|
||||
|
||||
|
||||
def test_자식_생긴_항목_묶음_합_전달_빠지면_다시_항목():
|
||||
plain = _calc()
|
||||
frozen, under, hands, alone, mat = _bills()
|
||||
base = _rows(plain)
|
||||
rows = _rows(_calc(under))
|
||||
e1, e4 = rows["earth:1"], rows["earth:4"]
|
||||
assert (e4["kind"], e4["auto"], e4["qty"], e4["ref"], e4["price_sum"]) == (
|
||||
"group", True, None, None, None) # fmt: skip
|
||||
assert (e1["parent"], e1["level"], e4["level"]) == ("earth:4", 4, 3)
|
||||
# 묶음 금액 = 자손 합 → 1.2 · 1 로 전달 · 제 금액은 빠짐
|
||||
assert e4["sum"] == e1["sum"] == base["earth:1"]["sum"]
|
||||
assert rows["sec:1.2"]["sum"] == e1["sum"] and rows["sec:1.1"]["sum"] == 0
|
||||
assert rows["sec:1"]["sum"] == base["sec:1"]["sum"] - base["earth:4"]["sum"]
|
||||
gaps = _calc(under)["bill"]["gaps"]
|
||||
assert not [g for g in gaps if "earth:4" in json.dumps(g, ensure_ascii=False)]
|
||||
# 자식이 빠지면(원래 부모로) = 다시 항목 · 제 수량 · 단가 · 표 그대로
|
||||
assert _calc(_put(under, "earth:1", parent="sec:1.1"))["bill"] == plain["bill"]
|
||||
# 새 항목(수량 2 · 대가3) — 자식이 생기면 묶음(자식 합) · 빠지면 제 값 다시
|
||||
rows = _rows(_calc(hands))
|
||||
assert (rows["hand:a"]["kind"], rows["hand:a"]["sum"], rows["hand:c"]["level"]) == (
|
||||
"group", 1230, 3) # fmt: skip
|
||||
a = _rows(_calc(alone))["hand:a"]
|
||||
assert (a["kind"], a["qty"], a["sum"]) == ("item", 2, 2460)
|
||||
# 저장 다듬기 = 자식 있는 새 항목도 제 수량 · 연결 지킴(묶음은 셈이 정함)
|
||||
kept = {r["key"]: r for r in store.clean_bill(hands)["rows"]}["hand:a"]
|
||||
assert (kept.get("kind"), kept["qty"], kept["ref"]) == (None, 2, DG)
|
||||
# 자재 줄 = 부모 못 됨 → 기본 부모
|
||||
rows = _rows(_calc(mat))
|
||||
assert rows["earth:5"]["parent"] == "sec:1.2" and rows["mat:b08:sand"]["kind"] == "item"
|
||||
|
||||
|
||||
def test_거울_TS_가_같은_값():
|
||||
calc = _ts(["Calc_Base", "Calc_Bill"])
|
||||
users = [{**BASE, "bill": b} for b in _bills()]
|
||||
body = (
|
||||
f"\nconst i = {json.dumps(INPUTS, ensure_ascii=False)};"
|
||||
f"\nconst us = {json.dumps(users, ensure_ascii=False)};"
|
||||
f"\nconst b = {json.dumps(BOOKS, ensure_ascii=False)};"
|
||||
"\nconsole.log(JSON.stringify(us.map((u) => computeEstimation(i, u, null, b))));"
|
||||
)
|
||||
got = json.loads(_node_calc(calc + body))
|
||||
assert got == [compute_estimation(INPUTS, u, None, BOOKS) for u in users]
|
||||
@@ -0,0 +1,244 @@
|
||||
"""204-2 B09 설계내역서 연결 고르기 — 프로젝트 호표 + 서고(마스터 · 회사 · 개인)
|
||||
구조물조합 · 사전조합.
|
||||
|
||||
- 서고 고름 = 프로젝트 사본 들여옴(`B09_Estimation/v1/library/{폴더}`) + 새 대가 호표(`libs`) +
|
||||
줄 연결 `{tab: dg, key: 블록 id, lib: 폴더}` · 다시 고르면 같은 사본(만듦 거짓 · 같은 호표)
|
||||
- 사본 단가 묶음(로직 · 사전조합 자재 · 고른 자재) · 원본을 지워도 그대로 · 단가 수요 키에 듦
|
||||
- [저장] = 줄이 안 잇는 사본 지움 · 덮개 저장 꼴이 `lib` 를 지킴
|
||||
- 층 권한 = `M02_Layer_Access`(남의 회사 403 · 회사 없는 개인 409)
|
||||
- 화면 — 연결 메뉴 출처 다섯 · 사전조합 창(서고 줄만) → 들여오기 → 원가 자료 바꿔 끼움 · 줄 연결
|
||||
임시 층(견본 마스터 · 임시 storage) · 실제 마스터 · 프로젝트는 안 씀.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B09_Estimation import B09_Estimation_Bill_Library as library # noqa: E402
|
||||
from B09_Estimation import B09_Estimation_Router as router # noqa: E402
|
||||
from B09_Estimation import B09_Estimation_Store as store_b09 # 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 config import config_system # noqa: E402
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs # noqa: E402
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store # noqa: E402
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers # noqa: E402
|
||||
from resources.tester.helper_m02_master_sample import copy_master_sample # noqa: E402
|
||||
|
||||
STONE = "GF000219" # 돌쌓기 메쌓기 원/㎡
|
||||
NET = "GC000337" # 낙석방지망 — 고름 4 = NET_ITEM
|
||||
NET_ITEM = "MT006697"
|
||||
MAT = "MT000008"
|
||||
COMBO = "SS000011" # 견본 관공 관매설(옛 UA 조합 줄)
|
||||
IMPORTS = re.compile(r"^import[^;]*;\r?\n", re.MULTILINE)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def world(tmp_path, monkeypatch):
|
||||
system = copy_master_sample(tmp_path / "master")
|
||||
monkeypatch.setattr(store, "FOLDER", system)
|
||||
monkeypatch.setattr(layers, "SYSTEM_ROOT", system)
|
||||
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage"))
|
||||
lines = [
|
||||
{"로직": STONE, "수량": {"손": 2}},
|
||||
{"로직": NET, "수량": {"손": 1}, "고름": {"4": NET_ITEM}},
|
||||
]
|
||||
with store.at(system):
|
||||
key = refs.create(
|
||||
"precombo",
|
||||
{"이름": "서고 조합", "단위": "㎡", "담은로직": lines,
|
||||
"자재": [{"코드": MAT, "수량": {"손": 3}}]},
|
||||
)["이름"] # fmt: skip
|
||||
company = layers.company_dir(7)
|
||||
company.mkdir(parents=True)
|
||||
with store.at(company):
|
||||
other = refs.create("precombo", {"이름": "회사 조합", "담은로직": lines[:1]})["이름"]
|
||||
project = tmp_path / "storage" / "7" / "1" / "proj"
|
||||
project.mkdir(parents=True)
|
||||
return {"system": system, "key": key, "company": other, "root": project}
|
||||
|
||||
|
||||
def _block(root, folder):
|
||||
got = daega_inputs(root)
|
||||
bid = got["libs"][folder]
|
||||
return got, next(b for b in got["blocks"] if b["id"] == bid)
|
||||
|
||||
|
||||
def test_사전조합_고름_사본_들여옴_새_호표_다시_고르면_재사용(world):
|
||||
root = world["root"]
|
||||
made = library.import_copy(root, "precombo", "system", None, world["key"])
|
||||
folder = made["폴더"]
|
||||
assert made == {"폴더": f"precombo@system@-@{world['key']}", "만듦": True}
|
||||
copy = library.library_dir(root) / folder
|
||||
assert {p.name for p in copy.iterdir()} == {"일위대가.json", "단가.json", "manifest.json"}
|
||||
got, block = _block(root, folder)
|
||||
assert (block["kind"], block["name"], block["unit"]) == ("사전조합", "서고 조합", "㎡")
|
||||
kinds = [(ln["kind"], ln.get("qty")) for ln in block["lines"]]
|
||||
assert kinds == [("sangeun", 2.0), ("sangeun", 1.0), ("material", 3.0)]
|
||||
assert got["owners"] == {} # 구조물 사본 없어도 호표
|
||||
# 단가 묶음 = 담은 로직 · 사전조합 자재 · 고른 자재(사본 셈이 마스터를 안 읽음)
|
||||
bundle = json.loads((copy / "단가.json").read_text("utf-8"))
|
||||
text = json.dumps(bundle, ensure_ascii=False)
|
||||
assert all(code in text for code in (STONE, NET, MAT, NET_ITEM))
|
||||
keys = linked_resource_keys(root)
|
||||
assert MAT in keys and any(k.startswith("LB") for k in keys) # 단가 수요에 듦
|
||||
# 다시 고름 = 새로 안 들여옴 · 같은 호표
|
||||
again = library.import_copy(root, "precombo", "system", None, world["key"])
|
||||
assert again == {"폴더": folder, "만듦": False}
|
||||
assert _block(root, folder)[1]["id"] == block["id"]
|
||||
# 원본을 지워도 굳힌 사본 그대로
|
||||
(world["system"] / "precombo" / f"{world['key']}.json").unlink()
|
||||
assert _block(root, folder)[1]["id"] == block["id"]
|
||||
|
||||
|
||||
def test_구조물조합_고름_사본_조합_호표(world):
|
||||
root = world["root"]
|
||||
made = library.import_copy(root, "combo", "system", None, COMBO)
|
||||
copy = library.library_dir(root) / made["폴더"]
|
||||
names = {p.name for p in copy.iterdir()}
|
||||
assert {"집계.json", "일위대가.json", "단가.json", "입력값.json", "manifest.json"} <= names
|
||||
manifest = json.loads((copy / "manifest.json").read_text("utf-8"))
|
||||
assert manifest["종류"] == "combo" and manifest["원본"]["key"] == COMBO
|
||||
assert manifest["원본"]["경로"] # 가져온 때 경로
|
||||
_, block = _block(root, made["폴더"])
|
||||
assert block["kind"] == "조합" and block["lines"]
|
||||
assert block["origin"] == {"layer": "system", "owner": None, "key": COMBO}
|
||||
|
||||
|
||||
def test_없는_것_모양_틀림(world):
|
||||
root = world["root"]
|
||||
with pytest.raises(LookupError):
|
||||
library.import_copy(root, "precombo", "system", None, "SC-999999")
|
||||
with pytest.raises(LookupError):
|
||||
library.import_copy(root, "combo", "system", None, "SS999999")
|
||||
with pytest.raises(ValueError):
|
||||
library.import_copy(root, "precombo", "system", None, "../x")
|
||||
with pytest.raises(ValueError):
|
||||
library.import_copy(root, "other", "system", None, world["key"])
|
||||
assert library.copies(root) == []
|
||||
|
||||
|
||||
def test_저장_때_안_잇는_사본_지움_lib_지킴(world):
|
||||
root = world["root"]
|
||||
a = library.import_copy(root, "precombo", "system", None, world["key"])["폴더"]
|
||||
b = library.import_copy(root, "combo", "system", None, COMBO)["폴더"]
|
||||
ref = {"tab": "dg", "key": "DG:x", "lib": a}
|
||||
user = store_b09.clean_user(
|
||||
{"bill": {"v": 2, "rows": [{"key": "hand:1", "name": "줄", "qty": 1, "ref": ref}]}}
|
||||
)
|
||||
assert user["bill"]["rows"][0]["ref"] == ref # 저장 꼴이 lib 를 지킴
|
||||
assert library.prune(root, user) == [b]
|
||||
assert [f.name for f, _ in library.copies(root)] == [a]
|
||||
assert library.prune(root, {}) == [a] and library.copies(root) == []
|
||||
|
||||
|
||||
def _call(monkeypatch, root, session, **body):
|
||||
async def fake_root(_):
|
||||
return root
|
||||
|
||||
async def fake_payload(_, at):
|
||||
return {"inputs": {"daega": daega_inputs(at)}, "sheets": {}}
|
||||
|
||||
monkeypatch.setattr(router, "_project_root", fake_root)
|
||||
monkeypatch.setattr(router, "payload", fake_payload)
|
||||
req = router.LibraryBody(**body)
|
||||
return asyncio.run(router.import_library("00000000-0000-0000-0000-000000000001", req, session))
|
||||
|
||||
|
||||
def test_층_권한_길(world, monkeypatch):
|
||||
root = world["root"]
|
||||
mine = {"user_id": 1, "company_id": 7, "role": "USER", "name": "갑"}
|
||||
got = _call(monkeypatch, root, mine, kind="precombo", layer="company", key=world["company"])
|
||||
assert got["만듦"] and got["폴더"] == f"precombo@company@7@{world['company']}"
|
||||
assert got["key"] == got["payload"]["inputs"]["daega"]["libs"][got["폴더"]]
|
||||
assert json.loads((library.library_dir(root) / got["폴더"] / "manifest.json").read_bytes())[
|
||||
"가져온이"
|
||||
] == {"id": 1, "이름": "갑"}
|
||||
other = {"user_id": 2, "company_id": 8, "role": "USER"}
|
||||
with pytest.raises(HTTPException) as error:
|
||||
_call(monkeypatch, root, other, kind="precombo", layer="company", owner="7",
|
||||
key=world["company"]) # fmt: skip
|
||||
assert error.value.status_code == 403
|
||||
lonely = {"user_id": 3, "company_id": None, "role": "USER"}
|
||||
with pytest.raises(HTTPException) as error:
|
||||
_call(monkeypatch, root, lonely, kind="precombo", layer="personal", key=world["key"])
|
||||
assert error.value.status_code == 409
|
||||
with pytest.raises(HTTPException) as error:
|
||||
_call(monkeypatch, root, lonely, kind="precombo", layer="system", key="SC-999999")
|
||||
assert error.value.status_code == 404
|
||||
got = _call(monkeypatch, root, lonely, kind="precombo", layer="system", key=world["key"])
|
||||
assert got["만듦"] and got["key"] # 마스터 = 누구나 읽음
|
||||
|
||||
|
||||
# ── 화면 ──────────────────────────────────────────────────────────────
|
||||
LINK_JS = """
|
||||
const toasts = [];
|
||||
const sent = [];
|
||||
globalThis.showToast = (m, k) => toasts.push([k, m]);
|
||||
globalThis.API_BASE_URL = "/api";
|
||||
globalThis.CURRENT_PROJECT_ID_KEY = "pid";
|
||||
globalThis.localStorage = { getItem: () => "P1" };
|
||||
globalThis.openLogicPicker = (done, lib) => { sent.push(["pre", lib.layer, lib.libOnly, lib.title]);
|
||||
lib.pick({ 층: "company", 주인: "7", 키: "SC-000002" }, "회사 조합"); };
|
||||
globalThis.openItemPicker = async () => ({ action: "apply",
|
||||
item: { layer: "system", owner: null, key: "SS000011", name: "관매설" } });
|
||||
const answer = { 폴더: "precombo@company@7@SC-000002", 만듦: true, key: "DG:new",
|
||||
payload: { user: { bill: "서버 것" }, inputs: { daega: { libs: {} } },
|
||||
sheets: { daega: [{ key: "DG:new", name: "회사 조합", spec: "", unit: "㎡" }] } } };
|
||||
globalThis.fetch = async (url, init) => { sent.push([url, JSON.parse(init.body)]);
|
||||
return { ok: true, status: 200, json: async () => answer }; };
|
||||
const ctx = { sheets: {}, payload: { inputs: { old: 1 } }, user: { keep: 1 }, setUser() {} };
|
||||
const applied = [];
|
||||
const apply = (ref, from) => applied.push([ref, from && from.name]);
|
||||
const [picks, more] = linkItems(ctx, { tab: "dg", key: "DG:a" }, apply);
|
||||
const out = { picks: picks.map((x) => x[0]), more: more.map((x) => x[0]) };
|
||||
picks.find((x) => x[0].startsWith("서고 일위대가"))[1]();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
picks.find((x) => x[0].startsWith("서고 구조물"))[1]();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
Object.assign(out, { sent, applied, payload: ctx.payload, toasts });
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
def test_화면_출처_다섯_사전조합_창_들여오기_줄_연결_TS():
|
||||
src = IMPORTS.sub(
|
||||
"", (ROOT / "B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts").read_text("utf-8")
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript"],
|
||||
input=src + LINK_JS, capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
out = json.loads(result.stdout)
|
||||
assert out["picks"] == [
|
||||
"일위대가 호표",
|
||||
"단가산출 호표",
|
||||
"서고 구조물조합(마스터 · 회사 · 개인)",
|
||||
"서고 일위대가 사전조합(마스터 · 회사 · 개인)",
|
||||
"단가산출 로직(M01 · 새 산근)",
|
||||
]
|
||||
assert out["more"] == ["연결 풀기"]
|
||||
pre, (url, body) = out["sent"][0], out["sent"][1]
|
||||
assert pre == ["pre", "personal", True, "일위대가 사전조합 고르기"] # 서고 줄만 · 층 셋
|
||||
assert url == "/api/projects/P1/estimation/library"
|
||||
assert body == {"kind": "precombo", "layer": "company", "owner": "7", "key": "SC-000002"}
|
||||
assert out["sent"][2][1] == {"kind": "combo", "layer": "system", "owner": None,
|
||||
"key": "SS000011"} # fmt: skip
|
||||
ref = {"tab": "dg", "key": "DG:new", "lib": "precombo@company@7@SC-000002"}
|
||||
assert out["applied"][0] == [ref, "회사 조합"]
|
||||
# 원가 자료만 바꿔 끼움 — 사용자 값(서버 user)은 안 받음
|
||||
assert "user" not in out["payload"] and out["payload"]["inputs"] == {"daega": {"libs": {}}}
|
||||
assert out["toasts"][-1][0] == "success"
|
||||
@@ -0,0 +1,50 @@
|
||||
"""205-1 「구조물 배치」 맨 아래 [추가] · [삭제] · [리셋] 줄 — 단추 높이 = 상자 안 작은 단추 · 늘 보임.
|
||||
|
||||
- 높이 = `--b05-control-h`(상자 안 단추 · 입력 칸과 같은 값) · 상자 이름표 [삭제]와 같은 높이 식
|
||||
- 줄 = 스크롤 영역 아래 끝에 붙음(sticky · bottom 0) · 단추 · 줄에 hidden · display none · visibility ·
|
||||
호버 조건이 없음
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
CSS = (ROOT / "B05_Profile/B05_Profile_UI_Style_Structures.css").read_text(encoding="utf-8")
|
||||
FORM = (ROOT / "B05_Profile/B05_Profile_UI_Structures_Form.ts").read_text(encoding="utf-8")
|
||||
SECTION = ".b05-structure-section .b05-route__irregular-actions"
|
||||
|
||||
|
||||
def _block(selector: str) -> str:
|
||||
m = re.search(r"(?m)^" + re.escape(selector) + r"\s*\{([^}]*)\}", CSS)
|
||||
assert m, selector
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _decl(block: str, prop: str) -> str:
|
||||
m = re.search(r"(?:^|;|\s)" + re.escape(prop) + r"\s*:\s*([^;]+);", block)
|
||||
assert m, prop
|
||||
return m.group(1).strip()
|
||||
|
||||
|
||||
def test_buttons_same_height_as_inner_buttons():
|
||||
outer = _block(SECTION + " > button")
|
||||
inner = re.search(r"\.b05-structure__group > legend > button\s*\{([^}]*)\}", CSS).group(1)
|
||||
assert _decl(outer, "height") == "var(--b05-control-h)"
|
||||
for prop in ("height", "box-sizing", "padding-block", "font-size", "line-height"):
|
||||
assert _decl(outer, prop) == _decl(inner, prop), prop
|
||||
assert re.search(r"--b05-control-h:\s*26px;", CSS)
|
||||
|
||||
|
||||
def test_row_always_visible():
|
||||
row = _block(SECTION)
|
||||
assert _decl(row, "position") == "sticky"
|
||||
assert _decl(row, "bottom") == "0"
|
||||
assert "background" in row
|
||||
for text in (_block(SECTION), _block(SECTION + " > button")):
|
||||
assert not re.search(r"display\s*:\s*none|visibility|opacity|overflow", text)
|
||||
assert not re.search(re.escape(SECTION) + r"[^{]*(:hover|:focus-within|\[hidden\])", CSS)
|
||||
|
||||
|
||||
def test_form_never_hides_buttons():
|
||||
assert "removeButton.hidden" not in FORM and "resetButton.hidden" not in FORM
|
||||
assert "actions.hidden" not in FORM
|
||||
@@ -0,0 +1,159 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""PLAN 206-1 — 양측 성토 측점에 측구가 남는 흠(검증_sub1 No.1+0 사본 재현).
|
||||
|
||||
원인 — 측구 판정의 「양측 성토 = 측구 없음」이 **입력** `section_mode` 를 봤다. 입력은 옛 저장값
|
||||
(초기값 right_cut)이고, 계획고를 +1.98 m 올려 지형 판정(역할)은 양측 성토가 됐는데 입력이 그대로라
|
||||
옛 결과 `ditch_enabled=True` 가 사용자 선택으로 둔갑(ditch_choice=True)해 측구가 섰다.
|
||||
좌 · 우를 누르면 카드가 판정값 both_fill 을 입력으로 다시 보내 그때서야 사라졌다.
|
||||
고침 — 판정된 역할(양쪽 성토)로 가름 · 파이썬 · TS 짝 같은 규칙 · 옛 저장분은 낡음으로 다시 셈.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402
|
||||
|
||||
# 검증_sub1(7e420ed6) 노선 230 · No.1+0(20 m) 지반선 사본 — -20 ~ +20 m · 0.5 m 간격
|
||||
_Z = [
|
||||
885.0166, 884.8327, 884.6401, 884.4294, 884.2270, 883.7564, 883.2482, 882.8840, 882.5737,
|
||||
882.3786, 882.2795, 881.9692, 881.3788, 880.7761, 880.1219, 879.4944, 879.1738, 878.8550,
|
||||
878.6597, 878.4670, 878.1648, 877.8416, 877.5119, 877.1749, 876.8564, 876.5584, 876.2612,
|
||||
875.9646, 875.6791, 875.4169, 875.1405, 874.7286, 874.3079, 874.0026, 873.7065, 873.4074,
|
||||
873.1107, 872.7726, 872.4127, 872.1306, 871.9252, 871.7424, 871.6078, 871.3723, 870.7716,
|
||||
870.1964, 869.8586, 869.5135, 869.2563, 869.0105, 868.5769, 868.0912, 867.7274, 867.4369,
|
||||
867.1615, 866.9033, 866.6590, 866.4489, 866.2281, 865.9711, 865.7139, 865.4391, 865.1650,
|
||||
864.8953, 864.6289, 864.3219, 863.9996, 863.6703, 863.3343, 863.0443, 862.8261, 862.5776,
|
||||
862.2364, 861.9035, 861.6491, 861.3932, 861.1137, 860.8302, 860.6107, 860.4051, 860.2006,
|
||||
] # fmt: skip
|
||||
SAMPLES = [{"offset_m": -20.0 + 0.5 * i, "elevation_m": z} for i, z in enumerate(_Z)]
|
||||
ELEV = 873.900731 # 지금 계획고(초기값 871.9252 에서 +1.98 m)
|
||||
# 그 프로젝트 저장분과 같은 입력 — 옛 단면유형 · 옛 측구 결과
|
||||
STORED = {
|
||||
"ground_type": "ripping_rock",
|
||||
"ditch_side": "right",
|
||||
"ditch_type": "standard",
|
||||
"rock_boundary_offset_m": -0.5,
|
||||
"two_stage_slope": True,
|
||||
}
|
||||
|
||||
|
||||
_GROUP = {
|
||||
"road_width_m": 3,
|
||||
"shoulder_left_m": 0.5,
|
||||
"shoulder_right_m": 0.5,
|
||||
"ditch": {"top_width_m": 0.9, "bottom_width_m": 0.3, "depth_m": 0.3},
|
||||
"fill_slope_ratio": 1.2,
|
||||
}
|
||||
# 그 프로젝트 종단 options 의 표준 횡단면
|
||||
STANDARD = {
|
||||
"soil": {**_GROUP, "cross_slope_pct": {"min": 3, "max": 5}, "cut_slope_ratio": 1},
|
||||
"rock": {
|
||||
**_GROUP,
|
||||
"ditch_l_type": {"width_m": 0.5, "depth_m": 0.1},
|
||||
"cross_slope_pct": {"min": 3, "max": 5},
|
||||
"cut_slope_ratio": 0.4,
|
||||
},
|
||||
"paved": {
|
||||
**_GROUP,
|
||||
"cross_slope_pct": {"min": 1.5, "max": 2},
|
||||
"cut_slope_ratio": 1,
|
||||
"pavement_thickness_m": 0.2,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _design(section_mode: str, **kwargs):
|
||||
return compute_cross_design(
|
||||
SAMPLES, ELEV, section_mode=section_mode, standard=STANDARD, **STORED, **kwargs
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"stale",
|
||||
[
|
||||
{"ditch_enabled": True}, # 옛 결과만(초기값 → 계획고 변경 재계산)
|
||||
{"ditch_enabled": True, "ditch_choice": True}, # 둔갑한 선택이 저장된 뒤(지금 DB)
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("mode", ["right_cut", "both_cut", "left_cut"])
|
||||
def test_사본_양측_성토면_옛_입력이어도_측구_없음(mode: str, stale: dict) -> None:
|
||||
design = _design(mode, **stale)
|
||||
assert design["section_mode"] == "both_fill"
|
||||
assert design["ditch_enabled"] is False, "양측 성토인데 측구가 섰다"
|
||||
assert design["ditch_area_m2"] == 0.0
|
||||
assert design["ditch"] == {"type": "none"}
|
||||
assert design["ditch_choice"] is None, "옛 결과가 선택으로 남았다"
|
||||
|
||||
|
||||
def test_사본_옛_입력과_바른_입력이_같은_형상() -> None:
|
||||
"""좌 · 우를 눌러야만 사라지던 것 — 이제 입력이 옛 값이어도 처음부터 같은 형상 · 면적."""
|
||||
stale = _design("right_cut", ditch_enabled=True, ditch_choice=True)
|
||||
fresh = _design("both_fill")
|
||||
for key in ("design_line", "fill_area_m2", "cut_area_m2", "ditch_area_m2"):
|
||||
assert stale[key] == fresh[key], key
|
||||
assert fresh["fill_area_m2"] == pytest.approx(34.0513, abs=1e-4)
|
||||
# 우 노견 바깥에 측구 꼭짓점(-2.3 · -2.6 · -2.9 m = 상단 0.9 · 저폭 0.3 사다리꼴)이 없다
|
||||
offsets = {p["offset_m"] for p in fresh["design_line"]}
|
||||
assert not offsets & {-2.3, -2.6, -2.9}
|
||||
|
||||
|
||||
def test_한쪽이라도_절토면_측구_그대로() -> None:
|
||||
"""양측 성토 판정은 역할로만 — 우 절토 지형은 옛 입력 both_fill 이어도 측구 섬."""
|
||||
design = _design("both_fill", ditch_enabled=False)
|
||||
assert design["section_mode"] == "both_fill" # 이 지형은 양측 성토
|
||||
cut = compute_cross_design(SAMPLES, ELEV - 2.5, section_mode="both_fill", **STORED)
|
||||
assert cut["section_mode"] in ("right_cut", "both_cut")
|
||||
assert cut["ditch_enabled"] is True and cut["ditch_side"] == "right"
|
||||
|
||||
|
||||
def test_TS_짝도_같은_값(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""거울 — 같은 사본을 TS `computeCrossDesign` 에 넣어 파이썬과 같은지(측구 없음까지)."""
|
||||
import test_b06_cross_design_mirror as mirror
|
||||
|
||||
cases = [
|
||||
{
|
||||
"name": f"206-1 사본 {mode}",
|
||||
"samples": SAMPLES,
|
||||
"design_elevation_m": ELEV,
|
||||
"options": {
|
||||
"section_mode": mode,
|
||||
**STORED,
|
||||
"ditch_enabled": True,
|
||||
"ditch_choice": True,
|
||||
},
|
||||
}
|
||||
for mode in ("right_cut", "both_fill")
|
||||
]
|
||||
monkeypatch.setattr(mirror, "CASES", cases)
|
||||
py, ts = mirror._python_results(), mirror._ts_results(tmp_path)
|
||||
problems = []
|
||||
for case, left, right in zip(cases, py, ts):
|
||||
problems.extend(mirror._diff(left, right, case["name"]))
|
||||
assert not problems, "\n".join(problems[:20])
|
||||
assert all(item["ditch_enabled"] is False for item in ts)
|
||||
|
||||
|
||||
def _src(rel: str) -> str:
|
||||
return (PROJECT_ROOT / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_옛_저장분은_낡음으로_다시_셈() -> None:
|
||||
"""DB 에 남은 양측 성토 + 측구 결과 → B05 · B06 진입 때 브라우저가 다시 셈(공용 낡음 판정)."""
|
||||
common = _src("B06_Section/B06_Section_UI_Section_Common.ts")
|
||||
assert 'design.section_mode === "both_fill" && design.ditch_enabled === true' in common
|
||||
|
||||
|
||||
def test_측구가_그려져_있으면_단추를_숨기지_않음() -> None:
|
||||
"""179-2 숨김은 「막힘 + 그려진 측구 없음」일 때만 — 그려진 측구는 끌 수 있어야 함."""
|
||||
cd = _src("B06_Section/B06_Section_UI_Cross_Design.ts")
|
||||
assert "const ditchDrawn = design?.ditch_enabled === true;" in cd
|
||||
assert 'const hasDitch = state.mode !== "both_fill" || ditchDrawn;' in cd
|
||||
assert '(state.mode !== "both_fill" || ditchDrawn)' in cd # 측구형식도 같은 규칙
|
||||
@@ -0,0 +1,115 @@
|
||||
"""207-1 B06 알림 칩 다듬기 — 둥근 사각 · 내용 칸 겹쳐 띄움 · 고르면 접힘 · 고른 측점 강조.
|
||||
|
||||
동작은 `B06_Section_UI_Cross_Notice_Chips.ts` 를 Node 로 그대로 돌림(196-1 가짜 DOM 재사용).
|
||||
모양 · 흐름 밖(겹침) · 줄 높이만 재는 scroll-margin 은 CSS · 부르는 쪽 원문 대조.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
from test_196_1_b06_notice_chips import B06, PRELUDE
|
||||
|
||||
CSS = (B06 / "B06_Section_UI_Style_Cross.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _rule(selector: str) -> str:
|
||||
body = CSS[CSS.index(selector + " {") :]
|
||||
return body[: body.index("}")]
|
||||
|
||||
|
||||
def _node(body: str):
|
||||
script = (
|
||||
PRELUDE
|
||||
+ """
|
||||
const sel = { id: null };
|
||||
const picked = [];
|
||||
const bar2 = mod.exports.createNoticeChips((id) => { picked.push(id); sel.id = id; }, () => sel.id);
|
||||
const [chips2, panel2] = bar2.root.children;
|
||||
const chip2 = (key) => chips2.children.find((c) => c.dataset.noticeKey === key);
|
||||
const marks = () => panel2.children.map((b) => [b.dataset.stationId, b.classList.contains("is-selected")]);
|
||||
"""
|
||||
+ body
|
||||
+ "\nconsole.log(JSON.stringify(out));"
|
||||
)
|
||||
got = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, text=True, encoding="utf-8", timeout=60
|
||||
)
|
||||
assert got.returncode == 0, got.stderr
|
||||
return json.loads(got.stdout)
|
||||
|
||||
|
||||
def test_칩은_둥근_사각_공용_단추_반경_알약_아님():
|
||||
chip = _rule(".b06-section__notice-chip")
|
||||
assert "border-radius: var(--radius-buttons)" in chip
|
||||
assert "radius-pills" not in chip and "radius-sm" not in chip
|
||||
|
||||
|
||||
def test_내용_칸은_흐름_밖_카드_위에_겹침():
|
||||
panel = _rule(".b06-section__notice-list")
|
||||
assert "position: absolute" in panel and "top: 100%" in panel
|
||||
assert "z-index" in panel and "box-shadow: var(--shadow-lg)" in panel
|
||||
assert "background: var(--color-surface-raised)" in panel
|
||||
# 기준 = 고정 줄(sticky 는 absolute 의 기준 상자) · 그 사이에 기준을 바꾸는 position 없음
|
||||
assert "position: sticky" in _rule(".b06-section__notice-bar")
|
||||
assert "position" not in _rule(".b06-section__notice-blocks")
|
||||
|
||||
|
||||
def test_열고_닫아도_카드_자리_그대로_scroll_margin은_칩_줄_높이만():
|
||||
view = (B06 / "B06_Section_UI_Section_View.ts").read_text(encoding="utf-8")
|
||||
# 줄 높이 = 고정 줄 offsetHeight — 흐름 밖 내용 칸은 높이에 안 듦
|
||||
assert '"--b06-notice-h", `${fillSlopeNotice.root.offsetHeight}px`' in view
|
||||
assert "scroll-margin-top: calc(var(--b06-notice-h, 0px) + var(--spacing-8))" in CSS
|
||||
# 내용 칸만 흐름 밖 — 칩 줄은 흐름 안
|
||||
assert "position" not in _rule(".b06-section__notice-chips")
|
||||
|
||||
|
||||
def test_측점_고르면_그_카드로_가며_내용_칸_접힘():
|
||||
got = _node(
|
||||
"""
|
||||
bar2.update(items(["a", "b"], ["p"], []));
|
||||
chip2("fill").click();
|
||||
out.before = panel2.hidden;
|
||||
panel2.children[1].click();
|
||||
out.picked = picked.slice();
|
||||
out.after = panel2.hidden;
|
||||
out.rows = panel2.children.length;
|
||||
out.expanded = chips2.children.map((c) => c.getAttribute("aria-expanded"));
|
||||
"""
|
||||
)
|
||||
assert got["before"] is False
|
||||
assert got["picked"] == ["b"]
|
||||
assert got["after"] is True and got["rows"] == 0
|
||||
assert got["expanded"] == ["false", "false"]
|
||||
|
||||
|
||||
def test_고른_측점이_있으면_열_때_그_단추_강조_없으면_강조_없음():
|
||||
got = _node(
|
||||
"""
|
||||
bar2.update(items(["a", "b", "c"], ["b"], []));
|
||||
chip2("fill").click();
|
||||
out.none = marks();
|
||||
chip2("fill").click();
|
||||
sel.id = "b"; // 측점 횡단도를 고른 상태
|
||||
chip2("fill").click();
|
||||
out.fill = marks();
|
||||
chip2("pave").click();
|
||||
out.pave = marks();
|
||||
sel.id = "zz"; // 목록에 없는 측점
|
||||
chip2("fill").click();
|
||||
out.absent = marks();
|
||||
"""
|
||||
)
|
||||
assert got["none"] == [["a", False], ["b", False], ["c", False]]
|
||||
assert got["fill"] == [["a", False], ["b", True], ["c", False]]
|
||||
assert got["pave"] == [["b", True]]
|
||||
assert got["absent"] == [["a", False], ["b", False], ["c", False]]
|
||||
|
||||
|
||||
def test_고른_측점_넘김_부르는_쪽_이음과_강조_색():
|
||||
notice = (B06 / "B06_Section_UI_Cross_FillSlope_Notice.ts").read_text(encoding="utf-8")
|
||||
assert "createNoticeChips(onPick, selected)" in notice
|
||||
view = (B06 / "B06_Section_UI_Section_View.ts").read_text(encoding="utf-8")
|
||||
assert "() => selectedStationId," in view
|
||||
mark = _rule(".b06-section__notice-list > button.is-selected")
|
||||
assert "var(--color-danger)" in mark # 카드 고름 색과 같음
|
||||
assert "border-color: var(--color-danger)" in _rule(".b06-cross-card--selected")
|
||||
@@ -234,6 +234,7 @@ const camel = {
|
||||
two_stage_slope: "twoStageSlope",
|
||||
cut_slope_ratio: "cutSlopeRatio",
|
||||
ditch_enabled: "ditchEnabled",
|
||||
ditch_choice: "ditchChoice",
|
||||
surface_drop_m: "surfaceDropM",
|
||||
plan_radius_m: "planRadiusM",
|
||||
curve_outer_side: "curveOuterSide",
|
||||
|
||||
@@ -172,3 +172,24 @@
|
||||
outline: 2px solid var(--color-focus-ring);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* 레벨(PLAN 201-1 · level 옵션 · 보기 전용) — 레벨 열 왼쪽 맞춤 · 레벨마다 1em 들여쓰기(글 상자도) */
|
||||
.ui-data-table__table td.ui-data-table__lv {
|
||||
padding-left: calc(var(--spacing-8) + var(--dt-lv, 0) * 1em);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ui-data-table__table td.ui-data-table__lv.is-edit {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.ui-data-table__table td.ui-data-table__lv .ui-data-table__input {
|
||||
padding-left: calc(var(--spacing-8) + var(--dt-lv, 0) * 1em);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* 같은 값 세로 병합(PLAN 201-1 · mergeSame) — 한 칸 글은 위에 */
|
||||
.ui-data-table__table td.is-merged {
|
||||
vertical-align: top;
|
||||
padding-top: calc((var(--dt-row-h) - 1.3em) / 2);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
* 칸 누름(PLAN 144) = onCellClick(줄, 열) · 줄로 이동 = t.focusRow(줄)(스크롤 + 강조)
|
||||
* 줄 꼴(PLAN 155-2) = rowClass(줄) → 줄 class(예 is-title 굵게) · 안 주면 글 그대로
|
||||
* 창 끝까지(PLAN 199) = fillViewport → 표 칸 최대 높이를 창 아래 끝까지(줄이 늘어도 페이지 스크롤 0)
|
||||
* 레벨(PLAN 201-1 · 보기 전용) = level { key, of(줄) } → 그 열 왼쪽 맞춤 + 레벨마다 1em 들여쓰기(글 상자도)
|
||||
* 같은 값 세로 병합(PLAN 201-1) = mergeSame [열] → 위아래로 같은 값이 이어지면 한 칸(rowSpan) · 본문만
|
||||
* 앞 병합 열이 갈리면 뒤 병합 열도 갈림 · 빈 값 · 고칠 칸은 안 합침 · 칸 → 열 = `cellColumn`(병합 표는 data-c)
|
||||
* ========================================================================== */
|
||||
|
||||
import "./ui_template_data_table.css";
|
||||
@@ -65,6 +68,10 @@ export interface DataTableOptions {
|
||||
cellClass?: (index: number, key: string) => string | null | undefined;
|
||||
/** 참이면 표 칸 최대 높이 = 창 아래 끝까지(창 크기 바뀌면 다시 셈) — 안 주면 CSS 고정값 */
|
||||
fillViewport?: boolean;
|
||||
/** 레벨(보기 전용) — key 열을 레벨마다 1em 들여씀 · of = 줄 레벨(1 = 맨 위 · 빈 값 = 1) */
|
||||
level?: { key: string; of: (index: number) => number | null | undefined };
|
||||
/** 같은 값 세로 병합할 열 키(표마다 지정) — 위아래로 같은 글이 이어지면 rowSpan 한 칸 */
|
||||
mergeSame?: string[];
|
||||
}
|
||||
|
||||
export interface DataTable {
|
||||
@@ -145,15 +152,21 @@ function rowHtml(
|
||||
focus = false,
|
||||
extra: string | null | undefined = null,
|
||||
cellClass?: (key: string) => string | null | undefined,
|
||||
more: RowMore = {},
|
||||
): string {
|
||||
let tr = "";
|
||||
const { lv, spans } = more;
|
||||
for (let j = 0; j < cols.length; j++) {
|
||||
const key = cols[j].key;
|
||||
const span = spans?.[j] ?? 1;
|
||||
if (!span) continue; // 위 칸에 합쳐짐
|
||||
const lvc = lv?.key === key ? " ui-data-table__lv" : "";
|
||||
const c = spans ? ` data-c="${j}"` : "";
|
||||
if (edit !== null && editable?.(edit, key)) {
|
||||
const v = r[key];
|
||||
const raw = v === null || v === undefined ? "" : String(v);
|
||||
const tip = esc(cols[j].title ?? cols[j].label);
|
||||
tr += `<td class="is-edit" data-num=""><input class="ui-data-table__input" data-i="${edit}" data-k="${esc(key)}" value="${esc(raw)}" inputmode="decimal" aria-label="${tip}"></td>`;
|
||||
tr += `<td class="is-edit${lvc}" data-num=""${c}><input class="ui-data-table__input" data-i="${edit}" data-k="${esc(key)}" value="${esc(raw)}" inputmode="decimal" aria-label="${tip}"></td>`;
|
||||
continue;
|
||||
}
|
||||
const [txt, empty] = formatCell(r[key], fmts[j], blank);
|
||||
@@ -163,24 +176,68 @@ function rowHtml(
|
||||
frz && left[j + 1] == null && "is-edge",
|
||||
empty && "is-empty",
|
||||
cellClass?.(key),
|
||||
lvc.trim(),
|
||||
span > 1 && "is-merged",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const attrs =
|
||||
(cls ? ` class="${cls}"` : "") +
|
||||
(frz ? ` style="left:${left[j]}px"` : "") +
|
||||
(fmts[j] ? ' data-num=""' : "");
|
||||
(fmts[j] ? ' data-num=""' : "") +
|
||||
(span > 1 ? ` rowspan="${span}"` : "") +
|
||||
c;
|
||||
tr += `<td${attrs}>${esc(txt)}</td>`;
|
||||
}
|
||||
const cls = [alt && "is-alt", index !== null && "is-clickable", focus && "is-focus", extra]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const data = index !== null ? ` data-i="${index}"` : "";
|
||||
const data =
|
||||
(index !== null ? ` data-i="${index}"` : "") + (lv ? ` style="--dt-lv:${lv.n}"` : "");
|
||||
return cls || data
|
||||
? `<tr${cls ? ` class="${esc(cls)}"` : ""}${data}>${tr}</tr>`
|
||||
: `<tr>${tr}</tr>`;
|
||||
}
|
||||
|
||||
/** 줄 곁값 — lv = 레벨 열 · 들여쓸 칸 수 · spans = 열마다 rowSpan(0 = 위 칸에 합쳐짐) */
|
||||
interface RowMore {
|
||||
lv?: { key: string; n: number };
|
||||
spans?: number[];
|
||||
}
|
||||
|
||||
/** 칸 → 열 번호 — 병합 표는 칸이 빠져 cellIndex 가 밀리므로 data-c */
|
||||
export function cellColumn(cell: HTMLTableCellElement): number {
|
||||
return cell.dataset.c !== undefined ? Number(cell.dataset.c) : cell.cellIndex;
|
||||
}
|
||||
|
||||
/** 병합 구간(from ~ to 줄) — [줄][열] rowSpan · 0 = 위 칸에 합쳐짐 · 병합 안 하는 열 = 1 */
|
||||
export function mergeSpans(o: DataTableOptions, from: number, to: number): number[][] {
|
||||
const cols = o.columns;
|
||||
const keys = new Set(o.mergeSame ?? []);
|
||||
const out = Array.from({ length: to - from }, () => cols.map(() => 1));
|
||||
// 이음 = 이 열까지 앞 병합 열이 모두 위 줄과 같은 글(빈 값 · 고칠 칸 빼고)
|
||||
const shown = (i: number, j: number): string | null => {
|
||||
const key = cols[j].key;
|
||||
if (o.editable?.(i, key)) return null;
|
||||
const [txt] = formatCell(o.rows[i][key], null, "");
|
||||
return txt.trim() ? txt : null; // 빈 값 · 빈칸 글(" ")은 안 합침
|
||||
};
|
||||
for (let i = from + 1; i < to; i++) {
|
||||
let joined = true;
|
||||
for (let j = 0; j < cols.length; j++) {
|
||||
if (!keys.has(cols[j].key)) continue;
|
||||
const v = shown(i, j);
|
||||
joined = joined && v !== null && v === shown(i - 1, j);
|
||||
if (!joined) continue;
|
||||
let top = i - 1;
|
||||
while (!out[top - from][j]) top--;
|
||||
out[top - from][j]++;
|
||||
out[i - from][j] = 0;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 그릴 줄 구간 — 밖의 줄은 빈 줄 높이(rowH px)로만 채움 */
|
||||
export interface RowWindow {
|
||||
from: number;
|
||||
@@ -216,7 +273,9 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin
|
||||
let body = win ? pad(from * win.rowH, cols.length) : "";
|
||||
const blank = o.empty ?? EMPTY;
|
||||
const pick = o.onRowClick !== undefined || o.onCellClick !== undefined;
|
||||
for (let i = from; i < to; i++)
|
||||
const spans = o.mergeSame?.length ? mergeSpans(o, from, to) : null;
|
||||
for (let i = from; i < to; i++) {
|
||||
const lvN = o.level ? Math.max(0, (o.level.of(i) ?? 1) - 1) : 0;
|
||||
body += rowHtml(
|
||||
cols,
|
||||
fmts,
|
||||
@@ -230,7 +289,12 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin
|
||||
o.focus === i,
|
||||
o.rowClass?.(i),
|
||||
o.cellClass ? (key) => o.cellClass?.(i, key) : undefined,
|
||||
{
|
||||
...(o.level ? { lv: { key: o.level.key, n: lvN } } : {}),
|
||||
...(spans ? { spans: spans[i - from] } : {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
if (win) body += pad((n - to) * win.rowH, cols.length);
|
||||
const foot = o.total ? `<tfoot>${rowHtml(cols, fmts, left, o.total, false, blank)}</tfoot>` : "";
|
||||
const cap = o.caption ? `<caption>${esc(o.caption)}</caption>` : "";
|
||||
@@ -342,7 +406,7 @@ export function createDataTable(opts: DataTableOptions): DataTable {
|
||||
const row = (event.target as Element).closest<HTMLElement>("tr[data-i]");
|
||||
if (!row || !root.contains(row)) return;
|
||||
const cell = (event.target as Element).closest<HTMLTableCellElement>("td");
|
||||
const column = cell ? state.columns[cell.cellIndex] : undefined;
|
||||
const column = cell ? state.columns[cellColumn(cell)] : undefined;
|
||||
if (column) state.onCellClick?.(Number(row.dataset.i), column.key);
|
||||
state.onRowClick?.(Number(row.dataset.i));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user