Merge remote-tracking branch 'origin/dev' into sub_laptop_7

This commit is contained in:
2026-10-10 14:48:41 +09:00
30 changed files with 1259 additions and 62 deletions
@@ -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;
@@ -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,
+3 -2
View File
@@ -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({
+22 -4
View File
@@ -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:
@@ -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
+11 -4
View File
@@ -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
+12 -3
View File
@@ -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;
}
+28 -3
View File
@@ -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)
+11 -5
View File
@@ -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
+38 -2
View File
@@ -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"])
@@ -141,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)
@@ -158,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)
+4 -2
View File
@@ -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
+13
View File
@@ -159,3 +159,16 @@
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);
}
@@ -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,
@@ -97,6 +98,7 @@ export const buildBill: TabBuild = (ctx): BillTable => {
},
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;
@@ -5,12 +5,14 @@
* 동작 = 줄 우클릭 메뉴(`…_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";
/** [단추 글, 메뉴 글] — 차례 = 들여 · 내어 · 위 · 아래 */
@@ -21,11 +23,14 @@ const ACTS: [string, string][] = [
["↓ 아래로", "아래로 ↓"],
];
const GUIDE = "레벨 — 줄을 누르고 단추 · 키 Tab 들여 · Shift+Tab 내어 · Alt+↑↓ 옮김";
const GUIDE =
"레벨 — 줄을 누르고 단추 · 방향키 ←→ 레벨 · ↑↓ 옮김(Tab · Shift+Tab · Alt+↑↓ 도) · 줄 끌기";
const GUIDE_MORE =
"레벨 = 공종 · 명칭 칸 왼쪽 들여쓰기(레벨마다 한 칸) · 묶음 금액 = 아래 줄 합 · " +
"들여쓰기 = 바로 위 묶음 줄 아래로 · 내어쓰기 = 부모 묶음 밖으로(맨 위 공종은 [새 공종 추가]) · " +
"들여쓰기 = 바로 위 줄 아래로(항목이면 저절로 묶음 · 자식이 다 빠진 묶음 = 다시 항목) · " +
"내어쓰기 = 부모 묶음 밖으로(맨 위 공종은 [새 공종 추가]) · " +
"위로 · 아래로 = 같은 부모 안에서 아래 줄째 자리 바꿈 · 못 하는 동작 = 흐린 단추 · " +
"끌기 = 줄을 잡아 위아래 = 자리 · 좌우 = 레벨(아래 줄째 · 빨간 선 = 못 놓음) · " +
"키는 글 칸 편집 중이 아닐 때(Esc = 편집 끝 · 한 번 더 Esc = 고름 풂) · " +
"묶음 줄로 바꾸기 · 더하기 · 지우기 = 줄 우클릭";
@@ -56,8 +61,11 @@ export function keyAct(
): string | null {
if (editing || e.ctrlKey) return null;
if (e.key === "Tab" && !e.altKey) return ACTS[e.shiftKey ? 1 : 0][1];
if (!e.altKey || e.shiftKey) return null;
return e.key === "ArrowUp" ? ACTS[2][1] : e.key === "ArrowDown" ? ACTS[3][1] : null;
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 =>
@@ -122,6 +130,7 @@ export function levelBar(root: HTMLElement, table: BillTable): HTMLElement {
picked = String(row.key);
sync();
});
attachDrag(root, table, (key) => (picked = key));
sync();
live = { root, bar, run, sync };
listenKeys();
@@ -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.이름)),
+11 -5
View File
@@ -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"),
);
}
@@ -81,3 +81,7 @@
58. 제비율표는 일력하는 공사원가계산서와 총괄설계내역서의 내용에서 요율을 원문 제비율표를 보고 누락이 된게 없는지 봐줘. (조사만)
59. 현재 B08/B09의 테이블 컨테이너의 양식을 보면 제일상단에 경고줄이 있어 이걸제일하단으로 옮겨줘. 이때 박스보기와 가로 카드수 정의는 보조탭 행에 우측맞춤으로 이동
60. 다음으로 B09의 좌측패널의 원가 단가 컨테이너의 이름을 데이터 점검으로 이름을 변경하고 내부의 쓸대없는 설명은 삭제, 단가 갱신버튼의 제목을 업데이트, 업데이트가 필요한경우 버튼에 오버레이 마크 추가, 상세 페이지의 연결점검 탭은 데이터 점검 컨테이너 내부로 이동
61. B09의 설계내역서에서 행들을 레벨이나 이동을 드래그나 키보드의 방향키로 제어가 되어야함. 구현하고 결국 내어쓰기와 들여쓰기는 결국 레벨이 되어야함. 상위 레벨로 이동이 되면 자연스럽게 하위항목의 합계가 세부항목이 되면 상위로 데이터를 전달하는 역할을 그리고 만약 기존 상위에 있던것들이 하위로 이동할경우에는 같이 이동되는 기능도 있어야함. 다음으로 일위대가와 단가산출의 정보를 연결할때 중요한건 마스터/회사/개인의 정보중 구조물조합, 일위대가사전조합, 현재처럼 벌써 들어와 있는 일위대가와 호표를 지정할 수도 있어야함.
62. B09의 좌측 패널의 컨테이너를 공용양식을 사용한건지 확인 (확인만)
63. B05/B06의 구조물배치 컨테이너에서 삭제버튼과 리셋버튼이 높이사이즈를 하위 컨테이너의 버튼 높이와 동일하게 구현하고 내부 컨테이너에서 삭제와 리셋버튼이 항상보이게 구현
64. [사진] 현재 검증 서브1 프로젝트의 1+0 측점의 내용임. 이때 원래는 측구가 없어야하는 성토사면에 존재함. 이때 측구버튼과 일반L형 버튼이 없음. 숨기기 결과로 보임. 이런 현상의 원인이 궁금함. 한번 좌우를 바꾸면 재현안됨. 확실히 조사하여 반영할것. (사진: 1+0 · 양측 성토 · 성토사면 좌 18.61m · 우 0.26m · 좌|우 단추 중 「우」 켜짐 · 오른쪽 노견 바깥에 작은 측구 모양 · 측구 · 일반/L형 단추 안 보임)
+2 -1
View File
@@ -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:"]
+7 -1
View File
@@ -317,7 +317,13 @@ def test_연결_단추_칸_메뉴_고르기_창_TS():
] # 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]]
+5 -2
View File
@@ -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
# 조달수수료 · 관급 자재 = 연결 · 옮김 · 지움 없음 · 관급자재대 제목 = 옮김 · 지움 없음
@@ -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")'),
@@ -122,7 +122,8 @@ out.keyAct = [
console.log(JSON.stringify(out));
"""
SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill", "UI_Tabs_Bill_Level"]
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 = "들여쓰기 →", "내어쓰기 ←", "위로 ↑", "아래로 ↓"
@@ -168,7 +169,8 @@ def test_키_셋_편집_중엔_안_먹음():
# 초점이 표 · 도구 줄 밖 = 안 먹음 · Esc(편집 아님) = 고름 풂 → 흐림 · Tab 은 기본 동작
assert out["outside"] == [False, []]
assert out["unpick"] == [[True] * 4, False, False]
assert out["keyAct"] == [IN, OUT, UP, DOWN, None, None, None]
# 그냥 ↑ = 옮기기(204 방향키 · test_204_1)
assert out["keyAct"] == [IN, OUT, UP, DOWN, None, UP, None]
def test_왼쪽_맞춤_레벨_들여쓰기_글자_크기_한_벌():
@@ -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,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")