Merge origin/sub_laptop_5 into sub_laptop_8 — PLAN 701 · B09 껍데기 겹침 양쪽 살림(띠 위 · 표 · 펼침 · 안내)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,8 @@
|
||||
관 몫 = 관 공 › 관매설 · 커플링에 더함(B06 새 키 `ford_pipe_length_m` × 련 · 131)
|
||||
물넘이포장 › 월류 폭 › H={월류 높이} m
|
||||
노출형 횡단수로 · 개거 · 맹암거(B05 구조물) 개소
|
||||
석축 · 옹벽(C군 벽 · 구조물 표 형상 wall) › H=높이 m 길이 = 시작~종료(옛 길이 칸) ·
|
||||
빈 높이 = 「미입력」 열 · 값 빈칸(금액 안 듦 · 701-1)
|
||||
흙막이 · 돌붙임 열은 관 · 박스 · 세월교 공용(실무 꼴) — 빈 높이: 관 = 관경 최소 ·
|
||||
박스 = 박스 높이 · 세월교 = 「미입력」(본체 끝 높이는 기하에만)
|
||||
열 id 접두 `pipe:` = 이 집계표(횡단배수) 이름공간 · 같은 측점 박스 + 관은 종류로 갈라 셈.
|
||||
@@ -50,6 +52,7 @@ from B06_Section.B06_Section_Repository import (
|
||||
)
|
||||
from B08_Quantity.B08_Quantity_Router_Hydro import _project_root
|
||||
from B08_Quantity.B08_Quantity_Summary_Sheet import _linked, _Sheet, unlinked_facilities
|
||||
from common_util.common_util_structure_catalog import type_ids_with
|
||||
from common_util.common_util_drainage_pipes import pipe_points_path_in, read_pipe_points_file
|
||||
from common_util.common_util_summary_item import GROUP_JOIN
|
||||
from config.config_db import get_db_pool
|
||||
@@ -79,6 +82,8 @@ SIDE_NAMES = {"inlet": "유입", "outlet": "유출"}
|
||||
SIMPLE = ("cross_drain_exposed", "open_ditch", "cross_underdrain")
|
||||
#: 이 집계표가 펼치는 종류 — 화면은 이 종류를 조합 열에서 뺌(응답 `types`)
|
||||
DRAIN_TYPES = ("pipe", "box_culvert", "ford_bridge", "ford_pavement", *SIMPLE)
|
||||
#: 석축 · 옹벽 — 구조물 표 형상 wall(본보기 복사 종류 포함 · 701-1)
|
||||
WALL_TYPES = tuple(sorted(type_ids_with(shape="wall")))
|
||||
FORMS = ("돌쌓기(찰)", "돌쌓기(메)", "콘크리트", "돌망태", "통나무·목재틀", "바자")
|
||||
SHAPES = ("I", "L", "U")
|
||||
BONDS = ("찰붙임", "메붙임")
|
||||
@@ -460,6 +465,26 @@ def _drain(
|
||||
_chutes(sheet, at, options, links, pipe_names)
|
||||
|
||||
|
||||
def _wall(sheet: _Sheet, item: dict[str, Any], combo: str | None, kinds: dict[str, Any]) -> None:
|
||||
"""석축 · 옹벽 한 곳 — 높이마다 길이 m · 빈 높이 = 「미입력」 열 · 값 빈칸(701-1)."""
|
||||
type_id = item["type_id"]
|
||||
sheet.owner, sheet.kind = item["복사본"], type_id
|
||||
title = next(
|
||||
(t["name"] for t in kinds.get("types") or [] if t.get("type_id") == type_id), type_id
|
||||
)
|
||||
height = _num(item["options"].get("height_m"))
|
||||
text = fill._label("height_m", height)
|
||||
sheet.add(
|
||||
float(item["chainage_m"]),
|
||||
f"wall|{type_id}|{text}",
|
||||
(7, _order(WALL_TYPES, type_id), math.inf if height is None else height),
|
||||
[title, fill.EMPTY_TEXT if height is None else f"H={text}"],
|
||||
"m",
|
||||
fill._length(item) if height is not None else None,
|
||||
[combo],
|
||||
)
|
||||
|
||||
|
||||
def pipe_summary(
|
||||
project_root: str | Path,
|
||||
designs: list[dict[str, Any]],
|
||||
@@ -492,11 +517,14 @@ def pipe_summary(
|
||||
by_owner: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
# 집계표 밖 구조물 — 사본 하나라도 있나(조합 열은 화면이 그림)
|
||||
others: dict[tuple[str, str], bool] = {}
|
||||
walls: list[dict[str, Any]] = []
|
||||
for item in items:
|
||||
owner, _, link = item["복사본"].partition(GROUP_JOIN)
|
||||
if item["type_id"] not in DRAIN_TYPES:
|
||||
key = (item["type_id"], owner)
|
||||
others[key] = others.get(key, False) or item["복사본"] in linked
|
||||
if item["type_id"] in WALL_TYPES and not link:
|
||||
walls.append(item) # 석축 · 옹벽 본체 = 길이 열(701-1)
|
||||
continue
|
||||
entry = by_owner.setdefault((item["type_id"], owner), {"links": {}})
|
||||
# 거름 한 곳 — 사본 없는 잇기는 조합 없음으로(그 칸만 안 셈 · 143-1)
|
||||
@@ -596,6 +624,10 @@ def pipe_summary(
|
||||
_revets(sheet, at, wall_options, links, sources, wall, names, wall_in)
|
||||
_chutes(sheet, at, options, links, names)
|
||||
|
||||
for item in walls:
|
||||
linked_combo = _combo(item.get("summary_item")) if item["복사본"] in linked else None
|
||||
_wall(sheet, item, linked_combo, kinds)
|
||||
|
||||
out = sheet.result()
|
||||
notes = []
|
||||
missing = sheet.unlinked | {key for key, found in others.items() if not found}
|
||||
@@ -618,7 +650,7 @@ def pipe_summary(
|
||||
**out,
|
||||
"notes": notes,
|
||||
"one_length_m": one_m,
|
||||
"types": list(DRAIN_TYPES),
|
||||
"types": [*DRAIN_TYPES, *WALL_TYPES],
|
||||
"pipes": pipes,
|
||||
"linked": linked,
|
||||
# 154-1 · 줄 누름 → 바로 연결(154-2)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
토공(1.1~1.6) = B08 토공집계표 줄 짝(`EARTH` — 엑셀 항목 이름 ↔ 토공집계표 구분 · 종류 · 규격) ·
|
||||
단가 = 조합 밖 토공 산근 · 준비공 대가 — 원천 없음 → 빈칸 「연결 없음」(지시)
|
||||
구조물(2.1~2.3) = B08 구조물 집계표 열(사본 있을 때만 · 143) ·
|
||||
구조물군 → 공종 짝(`STRUCTURE_SECTION`)
|
||||
구조물군 → 공종 짝(`STRUCTURE_SECTION` · 석축 · 옹벽 = 2.3 · 701-1)
|
||||
· 커플링 열은 자재(관급자재대)라 뺌 · 단가 = 대가 호표(145-2 가 `hopyo` 로 줌 · 그전엔 빈칸)
|
||||
운반(3) = 엑셀 운반공 줄 · 수량 = B08 총괄자재 짝 · 거리 · 단가 = 운반 산근(원천 없음 → 빈칸)
|
||||
자재대 · 관급자재대 = 화면 짝이 대비표 「별도」 · 「관급」 표시로 짬(사용자 조작 즉시)
|
||||
@@ -45,7 +45,7 @@ EARTH: list[tuple[str, str, str, tuple[str, str, str | None] | None]] = [
|
||||
]
|
||||
|
||||
#: 구조물 집계표 열 id 머리 → 공종(그 밖 = 2.2 배수공) · 커플링 = 자재라 뺌
|
||||
STRUCTURE_SECTION = {"pipe:revet|": "2.1", "pipe:chute|": "2.3"}
|
||||
STRUCTURE_SECTION = {"pipe:revet|": "2.1", "pipe:chute|": "2.3", "pipe:wall|": "2.3"}
|
||||
SKIP_COLUMNS = ("pipe:cp|",)
|
||||
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
묶음 금액 = 자손 항목 합 + 「빈 줄 n」 ·
|
||||
구조물 블록 줄 키 꼬리 = 대가 블록 키(대가 번호 밀림에 안 흔들림).
|
||||
원가계산서로 넘길 합 = `sums`(cost direct 항목의 직접재료비 · 직접노무비 · 산출경비 ·
|
||||
supply 항목의 관급 순자재대 — 145-4 이음 꼴 · 빈 줄이 있으면 None) ·
|
||||
supply 항목의 관급 순자재대 — 145-4 이음 꼴 · 직접공사 = 아는 금액까지 + `blank`(701-3) · 관급 = 빈 줄이 있으면 None) ·
|
||||
화면 표시용 = `totals`(아는 금액 합 · 빈 줄 수 · 조달수수료).
|
||||
"""
|
||||
|
||||
@@ -502,14 +502,15 @@ def compute_bill(
|
||||
"direct": {**direct, "blank": blank},
|
||||
"owner": {"net_material": net, "fee": fee, "total": net + (fee or 0)},
|
||||
}
|
||||
# 원가계산서로 넘김(145-4 이음 꼴) — 빈 줄이 있으면 모르는 값 = None
|
||||
known = blank == 0
|
||||
# 원가계산서로 넘김(145-4 이음 꼴) — 빈 줄이 있어도 아는 금액까지(701-3 · 띠 「빈 줄 n · 미완성」 ·
|
||||
# 인쇄 · 확정은 빈 줄 0 일 때만) · 관급 = 모르는 줄이 있으면 None(그대로)
|
||||
owner_known = all(row["sum"] is not None for row in owner)
|
||||
sums_out = {
|
||||
"material": direct["material"] if known else None,
|
||||
"labor": direct["labor"] if known else None,
|
||||
"expense": direct["expense"] if known else None,
|
||||
"material": direct["material"],
|
||||
"labor": direct["labor"],
|
||||
"expense": direct["expense"],
|
||||
"supply_net": net if owner_known else None,
|
||||
"blank": blank,
|
||||
}
|
||||
return {"rows": rows, "totals": totals, "sums": sums_out, "gaps": gaps}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* 공종 나무 = 엑셀 꼴 고정(1 토공사 · 2 구조물공 · 3 운반공 · 4 자재대 · 관급자재대(제외금액)) · 공구 안 나눔
|
||||
* 항목 = 서버가 B08 수량으로 짠 줄 + 대비표 「별도」(4 자재대) · 「관급」(관급자재대) 자재 + 조달수수료
|
||||
* 금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 조달수수료 = ROUNDDOWN(순자재대 × 율 ÷ 100, 0)
|
||||
* 묶음 금액 = 자손 항목 합(빈 줄은 「빈 줄 n」) · 원가계산서로 넘길 합 = sums(빈 줄 있으면 null)
|
||||
* 묶음 금액 = 자손 항목 합(빈 줄은 「빈 줄 n」) · 원가계산서로 넘길 합 = sums(아는 금액까지 + blank · 701-3 · 관급만 빈 줄 있으면 null)
|
||||
* 단가 = `refPrice(ref)` 하나(dg 대가 목록 · sg 산근 목록 · logic = 서버 sg_hand 산근 · 그 밖 hopyo)
|
||||
* 자유 편집(184) = `user.bill` v2 덮개 {v: 2, rows: [표 차례 · 고친 칸 · 새 줄], gone: [지운 자동 줄]} ·
|
||||
* 옛 꼴(qty · 손 줄 after · parent) 그대로 읽음 · 저장 꼴 · 출력 줄 칸(계약) = 짝 .py 머리
|
||||
@@ -472,14 +472,15 @@ export function computeBill(
|
||||
direct: { ...direct, blank },
|
||||
owner: { net_material: net, fee, total: net + (fee || 0) },
|
||||
};
|
||||
// 원가계산서로 넘김(145-4 이음 꼴) — 빈 줄이 있으면 모르는 값 = null
|
||||
const known = blank === 0;
|
||||
// 원가계산서로 넘김(145-4 이음 꼴) — 빈 줄이 있어도 아는 금액까지(701-3 · 띠 「빈 줄 n · 미완성」 ·
|
||||
// 인쇄 · 확정은 빈 줄 0 일 때만) · 관급 = 모르는 줄이 있으면 null(그대로)
|
||||
const ownerKnown = owner.every((row) => row.sum !== null);
|
||||
const sumsOut = {
|
||||
material: known ? direct.material : null,
|
||||
labor: known ? direct.labor : null,
|
||||
expense: known ? direct.expense : null,
|
||||
material: direct.material,
|
||||
labor: direct.labor,
|
||||
expense: direct.expense,
|
||||
supply_net: ownerKnown ? net : null,
|
||||
blank,
|
||||
};
|
||||
return { rows, totals, sums: sumsOut, gaps };
|
||||
}
|
||||
|
||||
@@ -236,8 +236,12 @@ async def save_estimation(project_id: UUID, body: dict[str, Any] = Body(...)) ->
|
||||
|
||||
@router.post("/{project_id}/estimation/complete")
|
||||
async def estimation_complete(project_id: UUID) -> dict[str, Any]:
|
||||
"""B09 [>] — 단계 6 완료(요구 300 B1)."""
|
||||
await _project_root(project_id) # 없는 프로젝트는 404
|
||||
"""B09 [>] — 단계 6 완료(요구 300 B1) · 빈 줄 있으면 409(701-3 확정 = 빈 줄 0 · 서버 정본으로 셈)."""
|
||||
root = await _project_root(project_id) # 없는 프로젝트는 404
|
||||
sheets = (await payload(project_id, root))["sheets"]
|
||||
blank = (((sheets.get("bill") or {}).get("totals") or {}).get("direct") or {}).get("blank")
|
||||
if blank:
|
||||
raise HTTPException(409, f"빈 줄 {blank} · 미완성 — 확정은 빈 줄 0 일 때만")
|
||||
await mark_stage(str(project_id), 6, "next")
|
||||
return {"status": "success", "project_id": str(project_id)}
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
*
|
||||
* [업데이트] = 단가를 지금 M01 값으로 새로 굳힘 · 서버가 낡았다고 보면(`payload.stale` 까닭 한 줄)
|
||||
* 단추에 점 배지(`is-pending`) · 굳힌 시각 · 까닭 = 공용 호버
|
||||
* 연결 점검 = 「연결 없음」 줄 목록(누르면 그 탭 그 줄로 · 까닭 = 공용 호버) + 서버 알림 · M01 에 없는 키
|
||||
* 연결 점검 = 「빈 줄 n」(설계내역서 금액 모르는 줄 · 안내 · 집계표와 같은 수 · 701-2) 목록 +
|
||||
* 그 밖 확인 목록(일위대가 · 단가산출 · 재료 등 · 탭 · 키마다 하나)(누르면 그 탭 그 줄로 · 까닭 = 공용 호버) + 서버 알림 · M01 에 없는 키
|
||||
* ========================================================================== */
|
||||
|
||||
import { attachHoverInfo } from "@ui/ui_template_hover_info";
|
||||
@@ -12,7 +13,7 @@ import { createBadge } from "@ui/ui_template_badge";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
import { dateTimeText } from "../common_util/common_util_time";
|
||||
import { tabName, type Link, type TabId } from "./B09_Estimation_UI_Tabs";
|
||||
import { blankRows, tabName, type Link, type TabId } from "./B09_Estimation_UI_Tabs";
|
||||
|
||||
export interface CheckItem {
|
||||
link: Link;
|
||||
@@ -21,19 +22,36 @@ export interface CheckItem {
|
||||
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 BLANK_WHY = "금액 모름 — 단가연결 칸 램프 · 이은 호표의 빈 줄을 확인";
|
||||
|
||||
/** 빈 줄(설계내역서 줄마다 하나 · 까닭 모음) · 그 밖 확인 줄(탭 · 키마다 하나) · 알림 글 */
|
||||
export function checkItems(
|
||||
sheets: Row,
|
||||
payload: Row,
|
||||
): { items: CheckItem[]; others: CheckItem[]; notes: string[] } {
|
||||
const why = new Map<string, string[]>();
|
||||
const order: string[] = [];
|
||||
const named = new Map<string, Row>();
|
||||
for (const g of (sheets.gaps || []) as Row[]) {
|
||||
const id = `${g.tab}|${g.key}`;
|
||||
if (!why.has(id)) (why.set(id, []), order.push(id), named.set(id, g));
|
||||
const list = why.get(id)!;
|
||||
if (g.why && !list.includes(String(g.why))) list.push(String(g.why));
|
||||
}
|
||||
const item = (tab: string, key: string, name: unknown, reasons?: string[]): CheckItem => ({
|
||||
link: { tab: tab as TabId, key },
|
||||
tab: tabName(tab),
|
||||
name: String(name ?? ""),
|
||||
why: reasons?.length ? reasons.join(" · ") : BLANK_WHY,
|
||||
});
|
||||
const items = blankRows(sheets).map((r) => item("bill", r.key, r.name, why.get(`bill|${r.key}`)));
|
||||
const others = order
|
||||
.filter((id) => !id.startsWith("bill|"))
|
||||
.map((id) => item(named.get(id)!.tab, named.get(id)!.key, named.get(id)!.name, why.get(id)));
|
||||
const notes: string[] = [...(payload.notes || [])];
|
||||
const missing: string[] = payload.inputs?.missing || [];
|
||||
if (missing.length) notes.push(`M01 에 없는 키 ${missing.length} — ${missing.join(" · ")}`);
|
||||
return { items, notes };
|
||||
return { items, others, notes };
|
||||
}
|
||||
|
||||
/** [업데이트] 호버 글 — 굳힌 시각 + 낡은 까닭 */
|
||||
@@ -54,25 +72,24 @@ export function createCheckPanel(refresh: HTMLElement, goto: (link: Link) => voi
|
||||
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 box = document.createElement("div");
|
||||
box.className = "b09-check";
|
||||
attachHoverInfo(box, (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 = createText(`연결 없음 ${items.length}곳`, "muted");
|
||||
head.classList.add("b09-check__head");
|
||||
const head = (text: string): HTMLElement => {
|
||||
const p = createText(text, "muted");
|
||||
p.classList.add("b09-check__head");
|
||||
return p;
|
||||
};
|
||||
const list = (group: CheckItem[], offset: number): HTMLElement => {
|
||||
const rows = document.createElement("div");
|
||||
rows.className = "ui-sidebar-list";
|
||||
rows.append(
|
||||
...items.map((item, i) => {
|
||||
...group.map((item, n) => {
|
||||
const row = document.createElement("button");
|
||||
row.type = "button";
|
||||
row.className = "ui-sidebar-row";
|
||||
row.dataset.i = String(i);
|
||||
row.dataset.i = String(offset + n);
|
||||
const name = document.createElement("span");
|
||||
name.textContent = item.name;
|
||||
const tab = createBadge(item.tab, "neutral", { size: 18 });
|
||||
@@ -82,12 +99,27 @@ export function createCheckPanel(refresh: HTMLElement, goto: (link: Link) => voi
|
||||
return row;
|
||||
}),
|
||||
);
|
||||
return rows;
|
||||
};
|
||||
|
||||
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, ...made.others];
|
||||
const blank = [
|
||||
head(`빈 줄 ${made.items.length}`),
|
||||
...(made.items.length ? [list(made.items, 0)] : []),
|
||||
];
|
||||
const others = made.others.length
|
||||
? [head(`그 밖 확인 ${made.others.length}곳`), list(made.others, made.items.length)]
|
||||
: [];
|
||||
const notes = made.notes.map((text) => {
|
||||
const p = createText(text, "muted");
|
||||
p.classList.add("b09-check__note");
|
||||
return p;
|
||||
});
|
||||
list.replaceChildren(head, ...(items.length ? [rows] : []), ...notes);
|
||||
box.replaceChildren(...blank, ...others, ...notes);
|
||||
};
|
||||
return { list, draw };
|
||||
return { list: box, draw };
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import "./B09_Estimation_UI_Page.css";
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { createBanner } from "@ui/ui_template_banner";
|
||||
import {
|
||||
createButton,
|
||||
hideLoadingOverlay,
|
||||
@@ -41,7 +42,7 @@ import { closePopups, openRowPopup } from "./B09_Estimation_UI_Popup";
|
||||
import {
|
||||
DRAFT,
|
||||
rowIndex,
|
||||
TABS,
|
||||
unfinished,
|
||||
type Link,
|
||||
type TabContext,
|
||||
type TabId,
|
||||
@@ -127,7 +128,7 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
let payload: Row | null = null;
|
||||
let user: Row = {};
|
||||
let sheets: Row = {};
|
||||
let current: TabId = TABS[0][0];
|
||||
let current: TabId = "bill"; // 701-2 첫 화면 = 설계내역서(빈 원가계산서 아님)
|
||||
|
||||
// 181-1 위 탭(md) · 아래 탭(sm) 두 줄 — 줄 그리기 = `…_UI_TabBar`
|
||||
const bars = createTabBars((tab) => show(tab));
|
||||
@@ -266,7 +267,9 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
if (level && hint) hint.textContent = `${level.guide.textContent} · ${hint.textContent}`;
|
||||
const foot = hint ?? level?.guide;
|
||||
const fold = bill?.fold?.(table.root); // 702-1 줄 펼침 = 표 아래
|
||||
panel.replaceChildren(table.root, ...(fold ? [fold] : []), ...(foot ? [foot] : []));
|
||||
// 701-3 띠(원가계산서 「빈 줄 n · 미완성」) = 표 위
|
||||
const banner = spec.banner ? [createBanner({ tone: "warning", text: spec.banner })] : [];
|
||||
panel.replaceChildren(...banner, table.root, ...(fold ? [fold] : []), ...(foot ? [foot] : []));
|
||||
if (at >= 0) requestAnimationFrame(() => table.focusRow(at, false)); // 스크롤만(표시 = 빨강)
|
||||
};
|
||||
|
||||
@@ -311,6 +314,7 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
? run("/estimation", "PUT", "원가 사용자 값을 저장했습니다.", { user })
|
||||
: Promise.resolve(true);
|
||||
DRAFT.save = saveChanged; // 초안 [인쇄 미리보기] = 저장 뒤 이동(506-3)
|
||||
DRAFT.blocked = () => (payload ? unfinished(sheets) : null); // 701-3 인쇄 · 확정 = 빈 줄 0
|
||||
const refresh = createButton({
|
||||
label: "업데이트",
|
||||
variant: "ghost",
|
||||
@@ -370,6 +374,8 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
left.addEventListener("ui-step-nav", (event) => {
|
||||
if ((event as CustomEvent<number>).detail !== 1 || !goNext || !projectId) return;
|
||||
event.stopPropagation();
|
||||
const why = DRAFT.blocked?.();
|
||||
if (why) return void showToast(why, "warning"); // 701-3 확정 = 빈 줄 0 일 때만
|
||||
void moveStage(projectId, goNext, [
|
||||
{ name: "원가 저장", run: saveChanged },
|
||||
{
|
||||
|
||||
@@ -56,6 +56,18 @@ export const TABS: Array<[TabId, string]> = [
|
||||
|
||||
export const tabName = (id: string): string => TABS.find(([t]) => t === id)?.[1] ?? id;
|
||||
|
||||
/** 빈 줄(701-2) = 설계내역서 금액 모르는 직접공사 항목 줄 — 안내 · 데이터 점검 · 집계표 · 원가계산서가 같은 수 */
|
||||
export const blankRows = (sheets: Row): Row[] =>
|
||||
((sheets?.bill?.rows || []) as Row[]).filter(
|
||||
(r) => r.kind === "item" && r.cost === "direct" && r.sum === null,
|
||||
);
|
||||
|
||||
/** 「빈 줄 n · 미완성」 띠 글(701-3) — 빈 줄 0 = null */
|
||||
export const unfinished = (sheets: Row): string | null => {
|
||||
const n = blankRows(sheets).length;
|
||||
return n ? `빈 줄 ${n} · 미완성 — 금액은 아는 줄까지 · 인쇄 · 확정은 빈 줄 0 일 때만` : null;
|
||||
};
|
||||
|
||||
/** 위 탭(181-1) — [위 탭 이름, 아래 탭들] · 아래 탭 하나뿐 = 위 탭 단독 · 첫 아래 탭 = 위 탭 키 */
|
||||
export const TAB_GROUPS: Array<[string, TabId[]]> = [
|
||||
["공사원가계산서", ["cost"]],
|
||||
@@ -95,6 +107,8 @@ export interface TabTable {
|
||||
frozen?: number;
|
||||
/** 표 위 안내 글(근거 · 끝수) */
|
||||
hint?: string;
|
||||
/** 표 위 띠 알림(공용 띠 · 경고) — 예 원가계산서 「빈 줄 n · 미완성」(701-3) */
|
||||
banner?: string;
|
||||
/** 칸 누름 → 갈 줄(호표 칸) */
|
||||
link?: (index: number, key: string) => Link | null;
|
||||
editable?: (index: number, key: string) => boolean;
|
||||
@@ -134,6 +148,8 @@ export const DRAFT: {
|
||||
next?: () => void;
|
||||
/** 고친 값 저장(셸 `saveChanged`) — 성공이면 참 · 초안 단추가 이동 전에 부름. */
|
||||
save?: () => Promise<boolean>;
|
||||
/** 인쇄 · 확정 막는 까닭(셸 · 701-3 빈 줄 있으면 글) — 없으면 null */
|
||||
blocked?: () => string | null;
|
||||
} = { builders: {}, extend: [], panels: [], actions: [] };
|
||||
|
||||
/** 201-1 호표 묶음 레벨(보기 전용) — 머리 · 합계(제목 줄) = 1 · 그 밖 = 2 · key 열을 들여씀 */
|
||||
|
||||
@@ -69,7 +69,7 @@ export const buildBill: TabBuild = (ctx): BillTable => {
|
||||
hint:
|
||||
"줄을 우클릭하면 항목 추가 · 줄 삭제 · 옮김 · 단가연결 칸 램프를 누르면 일위대가 · 단가산출 연결" +
|
||||
(direct.blank
|
||||
? ` · 빈 줄 ${direct.blank} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음`
|
||||
? ` · 빈 줄 ${direct.blank} — 계는 아는 금액만 · 원가계산서도 아는 금액까지 · 인쇄 · 확정은 빈 줄 0 일 때만`
|
||||
: ""),
|
||||
editOnDouble: true,
|
||||
editable: (i, key) => {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import type { DataRow } from "@ui/ui_template_data_table";
|
||||
import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
import {
|
||||
blankRows,
|
||||
money,
|
||||
qty,
|
||||
text,
|
||||
@@ -20,17 +21,17 @@ import {
|
||||
|
||||
const HINT = "줄을 누르면 목록표로 이동";
|
||||
|
||||
/** 안내 + 「연결 없음 n」(전개 안 한 내역 항목 — 조합 밖 토공 · 운반 산근 등) */
|
||||
const hint = (payload: Row, extra = ""): string => {
|
||||
const n = (payload.summary || {}).unlinked || 0;
|
||||
return `${HINT}${extra}${n ? ` · 연결 없음 ${n} — 전개 안 함(좌측 데이터 점검)` : ""}`;
|
||||
/** 안내 + 「빈 줄 n」(설계내역서와 같은 수 · 701-2 — 금액 모르는 줄은 집계에서 빠짐) */
|
||||
const hint = (sheets: Row, extra = ""): string => {
|
||||
const n = blankRows(sheets).length;
|
||||
return `${HINT}${extra}${n ? ` · 빈 줄 ${n} — 집계에서 빠짐(좌측 데이터 점검)` : ""}`;
|
||||
};
|
||||
|
||||
/** 줄이 없을 때 표 위 맨 앞 안내 */
|
||||
const EXPENSE_NONE = "경비 자원 없음";
|
||||
|
||||
function resourceTab(id: string, list: TabId, unitPrice: string, none = ""): TabBuild {
|
||||
return ({ payload }): TabTable => {
|
||||
return ({ payload, sheets }): TabTable => {
|
||||
const rows: DataRow[] = ((payload.summary || {})[id] || []).map((r: Row) => ({ ...r }));
|
||||
const sum = rows.reduce((acc, r) => acc + (typeof r.amount === "number" ? r.amount : 0), 0);
|
||||
return {
|
||||
@@ -47,7 +48,7 @@ function resourceTab(id: string, list: TabId, unitPrice: string, none = ""): Tab
|
||||
rows,
|
||||
total: rows.length ? { name: "계", amount: sum } : null,
|
||||
frozen: 2,
|
||||
hint: !rows.length && none ? `${none} · ${hint(payload)}` : hint(payload),
|
||||
hint: !rows.length && none ? `${none} · ${hint(sheets)}` : hint(sheets),
|
||||
link: (i) => (rows[i] ? { tab: list, key: String(rows[i].key) } : null),
|
||||
};
|
||||
};
|
||||
@@ -57,7 +58,7 @@ export const buildSumMaterial = resourceTab("sum_material", "material", "단가"
|
||||
export const buildSumLabor = resourceTab("sum_labor", "labor", "단가");
|
||||
export const buildSumExpense = resourceTab("sum_expense", "expense", "단가", EXPENSE_NONE);
|
||||
|
||||
export const buildSumMachine: TabBuild = ({ payload }): TabTable => {
|
||||
export const buildSumMachine: TabBuild = ({ payload, sheets }): TabTable => {
|
||||
const rows: DataRow[] = ((payload.summary || {}).sum_machine || []).map((r: Row) => ({ ...r }));
|
||||
const add = (key: string) =>
|
||||
rows.reduce((acc, r) => acc + (typeof r[key] === "number" ? (r[key] as number) : 0), 0);
|
||||
@@ -85,7 +86,7 @@ export const buildSumMachine: TabBuild = ({ payload }): TabTable => {
|
||||
}
|
||||
: null,
|
||||
frozen: 2,
|
||||
hint: hint(payload),
|
||||
hint: hint(sheets),
|
||||
link: (i) => (rows[i] ? { tab: "machine", key: String(rows[i].key) } : null),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type TabBuild,
|
||||
type TabContext,
|
||||
type TabTable,
|
||||
unfinished,
|
||||
} from "./B09_Estimation_UI_Tabs";
|
||||
|
||||
type Num = number | null;
|
||||
@@ -251,6 +252,7 @@ export const buildTotal: TabBuild = (ctx): TabTable => {
|
||||
rowClass: (i) => levelClass(rows[i]),
|
||||
level: nameLevel(rows),
|
||||
mergeSame: ["part"],
|
||||
banner: unfinished(ctx.sheets) ?? undefined,
|
||||
hint: "맨 위 내역 줄을 누르면 설계내역서 그 줄로 · 요율 칸에 적으면 사용자 값 · 지우거나 출처 칸을 누르면 자동 값",
|
||||
editable: (i, key) =>
|
||||
(key === "rate" && RATE_KEYS.has(String(rows[i]?.key))) ||
|
||||
@@ -336,6 +338,7 @@ export const buildCost: TabBuild = (ctx): TabTable => {
|
||||
rowClass: (i) => levelClass(rows[i]),
|
||||
level: nameLevel(rows),
|
||||
mergeSame: ["group", "part"],
|
||||
banner: unfinished(ctx.sheets) ?? undefined,
|
||||
hint: "고칠 칸은 간접재료비 · 줄을 누르면 총괄 그 줄로 이동",
|
||||
editable: (i, key) => key === "amount" && String(rows[i]?.key) in HAND,
|
||||
onEdit: (i, key, value) => editEstimate(ctx, String(rows[i]?.key), key, value),
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
* B09_Draft_Print_UI.ts
|
||||
* 인쇄 미리보기 등록 입구(PLAN 302-1 · 초안) — B09 셸이 `import.meta.glob("./draft/*_UI.ts")` 로 불러올 때
|
||||
* 좌측 하단 고정 줄(`DRAFT.actions`)에 [인쇄 미리보기] 단추 → 저장(`DRAFT.save`) 뒤 #/b09-print(`…_Print_Route`)
|
||||
* · 단계 그대로 · 저장 실패면 머묾 ·
|
||||
* · 단계 그대로 · 저장 실패면 머묾 · 빈 줄 있으면 막음(`DRAFT.blocked` · 701-3) ·
|
||||
* [>] 다음 길(`DRAFT.next` · 저장 · 단계 6 완료 뒤 · 요구 298-2)
|
||||
* draft 폴더째 지우면 단추 · 길 · 서버 길이 함께 사라짐
|
||||
* ========================================================================== */
|
||||
|
||||
import type { RoutePath } from "@config/config_frontend";
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import { createButton, showToast } from "@ui/ui_template_elements";
|
||||
import { navigateTo } from "../../A00_Common/router";
|
||||
import { DRAFT } from "../B09_Estimation_UI_Tabs";
|
||||
import { ROUTE } from "./B09_Draft_Print_Route";
|
||||
@@ -19,10 +19,13 @@ DRAFT.actions.push(() => {
|
||||
const button = createButton({
|
||||
label: "인쇄 미리보기",
|
||||
variant: "ghost",
|
||||
onClick: () =>
|
||||
onClick: () => {
|
||||
const why = DRAFT.blocked?.(); // 701-3 인쇄 = 빈 줄 0 일 때만
|
||||
if (why) return void showToast(why, "warning");
|
||||
void (DRAFT.save?.() ?? Promise.resolve(true)).then(
|
||||
(ok) => ok && navigateTo(ROUTE.path as RoutePath),
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
button.dataset.b09Print = "";
|
||||
return button;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""여러 항목 · 폴더를 층 사이 붙여넣기(PLAN 30-2) — 구조물 라이브러리 나무의 [복사] → [붙여넣기].
|
||||
|
||||
붙일 폴더(`dest`) 아래 새 항목 — 대상 층 새 키(SC · SP) · 열 `본뜬` = 원본(층 · 주인 · 키) ·
|
||||
같은 이름은 「이름(1)」 · 폴더는 안의 항목 · 빈 폴더까지(폴더 이름이 겹치면 「폴더(1)」).
|
||||
같은 이름은 「이름(1)」 · 폴더는 안의 항목 · 빈 폴더까지(폴더 이름이 겹치면 「폴더(1)」) ·
|
||||
복사 생성(대상 `복사이름`) = 새 이름 「원래 이름 복사」(701-4 · 층이 달라도 같은 이름 둘 안 생김).
|
||||
파일(도면 · 면적 · 입력 연결)은 `Items.copy_item` 이 옮김. 권한은 부르는 쪽 몫.
|
||||
"""
|
||||
|
||||
@@ -73,7 +74,8 @@ def paste(
|
||||
if found is None:
|
||||
raise store.StoreError(404, f"없는 항목 「{key}」")
|
||||
used = [*taken, *(p for _, p in plan)]
|
||||
path = tree.unique_path([*dest, found[-1]], lambda p: p in used)
|
||||
name = f"{found[-1]} 복사" if one.get("복사이름") else found[-1]
|
||||
path = tree.unique_path([*dest, name], lambda p: p in used)
|
||||
renamed += [path[-1]] if path[-1] != found[-1] else []
|
||||
plan.append((key, path))
|
||||
continue
|
||||
|
||||
@@ -467,6 +467,8 @@ export const editTree = (
|
||||
export interface TreeRef {
|
||||
경로: string[];
|
||||
키?: string;
|
||||
/** 복사 생성(701-4) — 새 이름 = 「원래 이름 복사」(같은 이름 둘 안 생김) */
|
||||
복사이름?: boolean;
|
||||
}
|
||||
|
||||
/** 복사해 둔 것 — 나무 종류(`kind` · 구조물 · 일위대가) · 복사한 층 · 고른 것들 */
|
||||
|
||||
@@ -85,6 +85,13 @@
|
||||
border-top: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* 701-4 수량 안 넣은 줄 경고 — 합계 아래 제 줄 · 좁은 카드에서도 줄바꿈 */
|
||||
.m02-labrow__name > .m02-labrow__noqty {
|
||||
display: block;
|
||||
max-width: 9rem;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.m02-labrow__move {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -493,6 +493,13 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
});
|
||||
const sum = (part: Partial<LaborCost> | undefined, c: (typeof COSTS)[number]): number | null =>
|
||||
part?.[c] ?? null;
|
||||
// 701-4 수량산출 빈 줄 = 반영 금액 없음 → 합계에서 빠짐 — 조용히 빠지지 않게 합계 줄에 경고
|
||||
const noQty = data.calc
|
||||
? data.rows.filter((row) => {
|
||||
const line = lineOf(lineId(row) ?? "");
|
||||
return !!line && (line.수량 === null || line.수량 === undefined);
|
||||
}).length
|
||||
: 0;
|
||||
const foot = data.rows.length
|
||||
? [
|
||||
el("tr", {
|
||||
@@ -500,7 +507,21 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
children: [
|
||||
el("td", { className: "m02-labrow__no", text: "" }),
|
||||
el("td", { className: "m02-labrow__kind", text: "" }),
|
||||
el("td", { className: "m02-labrow__name", text: tc("Prev_Sum") }),
|
||||
el("td", {
|
||||
className: "m02-labrow__name",
|
||||
children: [
|
||||
el("span", { text: tc("Prev_Sum") }),
|
||||
...(noQty
|
||||
? [
|
||||
el("small", {
|
||||
className: "ui-text ui-text--error m02-labrow__noqty",
|
||||
text: tc("Labor_NoQty").replace("{n}", String(noQty)),
|
||||
attrs: { "data-labor-noqty": String(noQty) },
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
el("td", { className: "m02-labrow__alias", text: "" }),
|
||||
el("td", { className: "m02-labrow__qty", text: "" }),
|
||||
el("td", { className: "m02-labrow__unit", text: "" }),
|
||||
|
||||
@@ -107,6 +107,10 @@ const TEXT = {
|
||||
Lib_Personal: ["개인", "Personal"],
|
||||
Labor_AlreadyRef: ["이미 담은 일위대가 조합 — {value}", "Already added — {value}"],
|
||||
Labor_Empty: ["빈 값 {n}", "{n} empty"],
|
||||
Labor_NoQty: [
|
||||
"수량 안 넣은 줄 {n} — 합계에서 빠짐",
|
||||
"{n} rows without quantity — left out of the sum",
|
||||
],
|
||||
Labor_Lost: [
|
||||
"로직 입력이 바뀌어 저장 때 빠지는 값 — {value}",
|
||||
"Inputs no longer in the logic (dropped on save) — {value}",
|
||||
|
||||
@@ -673,7 +673,12 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
const run = api.paste;
|
||||
if (!run || !opt.canEdit || !(await ready())) return;
|
||||
await apply(() =>
|
||||
run(opt.layer, owner, { kind: clipKind, from, refs: [{ 경로: [], 키: key }] }, dest),
|
||||
run(
|
||||
opt.layer,
|
||||
owner,
|
||||
{ kind: clipKind, from, refs: [{ 경로: [], 키: key, 복사이름: true }] }, // 701-4 「… 복사」
|
||||
dest,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -235,6 +235,8 @@ async function b09(withNext) {
|
||||
return { ok: true, status: 200, json: async () => any() };
|
||||
};
|
||||
const tabs = any();
|
||||
let unfinished = null; // 701-3 빈 줄 띠 글 — 있으면 [>] 막힘
|
||||
tabs.unfinished = () => unfinished;
|
||||
tabs.DRAFT = { builders: {}, extend: [], panels: [], actions: [] };
|
||||
if (withNext) tabs.DRAFT.next = () => calls.push("next");
|
||||
const nextBtn = Object.assign(new Node("button"), { disabled: true });
|
||||
@@ -290,6 +292,9 @@ async function b09(withNext) {
|
||||
out.completeFail = await nav(1);
|
||||
failOn = null;
|
||||
out.prev = await nav(-1);
|
||||
unfinished = "빈 줄 2 · 미완성";
|
||||
out.blocked = await nav(1);
|
||||
unfinished = null;
|
||||
// [초기화]
|
||||
const reset = dock.children[0];
|
||||
const press = async () => {
|
||||
|
||||
@@ -18,6 +18,7 @@ GROUPS = "B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts"
|
||||
# 기억(b_page_state) 대신 Map · 탭 줄(fillTabs) 대신 가짜 button 줄 · 단추(createButton) 대신 가짜 button · 호버(attachHoverInfo) = 부른 자리 기록 · 157-3 원문번호 풍선(raw) 짝
|
||||
STUBS = r"""
|
||||
const kept = new Map();
|
||||
const dateTimeText = (iso) => String(iso).replace("T", " "); // 593-1 PC 시간대 글 — 견본은 시간대 없음
|
||||
const readState = (name) => (kept.has(name) ? JSON.parse(kept.get(name)) : null);
|
||||
const writeState = (name, value) => kept.set(name, JSON.stringify(value));
|
||||
const createButton = (o) => {
|
||||
|
||||
@@ -245,6 +245,7 @@ def test_차례_계_종류(out):
|
||||
"cross_drain_exposed",
|
||||
"open_ditch",
|
||||
"cross_underdrain",
|
||||
*summary.WALL_TYPES, # 석축 · 옹벽 길이 열(701-1)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -125,15 +125,12 @@ def test_제목_합_빈_줄과_원가계산서_이음():
|
||||
assert rows["sec:1.1"]["sum"] == 361494 + 1119272 and rows["sec:1.1"]["blank"] == 0
|
||||
assert rows["sec:1"]["sum"] == 361494 + 1119272 + 11003825 # 빈 줄은 0 이 아니라 빈 줄 수
|
||||
assert rows["sec:1"]["blank"] == 1 and "빈 줄" not in rows["sec:1"]["note"] # 257-2 비고 빈 줄 없음
|
||||
# 145-4 이음 = {material, labor, expense, supply_net} · 빈 줄 있으면 None
|
||||
assert sheets["bill"]["sums"] == {
|
||||
"material": None,
|
||||
"labor": None,
|
||||
"expense": None,
|
||||
"supply_net": 69474220,
|
||||
}
|
||||
# 145-4 이음 = {material, labor, expense, supply_net, blank} · 701-3 빈 줄 있어도 아는 금액까지
|
||||
known = {"material": 2771766, "labor": 7565300, "expense": 2627525, "supply_net": 69474220}
|
||||
assert sheets["bill"]["sums"] == {**known, "blank": 2} # 절토(연암) · 막자갈(단가 없음)
|
||||
full = {**INPUTS, "bill": {"items": ITEMS[:3]}, "materials": MATERIALS[:-1]}
|
||||
sums = compute_estimation(full, USER)["bill"]["sums"]
|
||||
assert sums == {**known, "blank": 0}
|
||||
assert sums["labor"] == 307104 + 924896 + 6333300 # I9 + I10 + I14
|
||||
assert sums["material"] == 54390 + 194376 + 2043000 + 480000
|
||||
assert sums["expense"] == 2627525
|
||||
|
||||
@@ -106,7 +106,7 @@ def test_묶음_탭_단추가_보조_줄에_안내는_아래에():
|
||||
def test_잇기_쉘_안내_줄_아래_도구_비움():
|
||||
shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
|
||||
assert (
|
||||
"panel.replaceChildren(table.root, ...(foot ? [foot] : []))" in shell
|
||||
"panel.replaceChildren(...banner, table.root, ...(foot ? [foot] : []))" in shell # 701-3 띠 = 표 위
|
||||
) # 229-1 레벨 단추 = 보조 탭 줄 · 안내 글 = 표 아래 · 203 레벨 안내 + 탭 안내 = 한 영역(맨 아래)
|
||||
assert "...(hint ? [hint] : []), table.root" not in shell # 위 자리 없음
|
||||
assert re.search(r"bars\.draw\(tab\);\s*bars\.setTools\(\[\]\);", shell)
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
- 묶음 이름 = 데이터 점검 · 안 설명 글 없음(굳힌 시각 · 까닭 = [업데이트] 공용 호버)
|
||||
- [단가 갱신] → [업데이트] · 낡으면(서버 `book_stale` = 지금 M01 로 굳힌 책과 다름) 점 배지(is-pending)
|
||||
- 「연결 점검」 탭 내용 = 묶음 안 목록(누르면 그 탭 그 줄) · 위 탭 줄에서 뺌 · 첫 탭 = 공사원가계산서
|
||||
- 「연결 점검」 탭 내용 = 묶음 안 목록(누르면 그 탭 그 줄) · 위 탭 줄에서 뺌
|
||||
- 701-2 첫 화면 = 설계내역서 · 목록 머리 「빈 줄 n」 = 설계내역서 금액 모르는 줄(줄마다 하나 · 까닭 모음) ·
|
||||
그 밖(호표 · 재료) = 「그 밖 확인 n곳」(탭 · 키마다 하나)
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -31,15 +33,15 @@ def test_묶음_이름_단추_글_설명_없음():
|
||||
assert "check.draw(payload, sheets);" in SHELL
|
||||
|
||||
|
||||
def test_연결_점검_탭_없음_첫_탭_공사원가계산서():
|
||||
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 out["first"] == "cost" and out["groups"][0] == "공사원가계산서" # 엑셀 탭 차례 그대로
|
||||
assert 'let current: TabId = "bill";' in SHELL
|
||||
assert "buildCheck" not in SHELL and "check:" not in SHELL
|
||||
|
||||
|
||||
@@ -50,43 +52,64 @@ def test_좌측_목록_이동_호버_배지():
|
||||
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 sheets = {
|
||||
bill: { rows: [
|
||||
{ kind: "title", cost: "direct", key: "sec:1", name: "토공사", sum: null },
|
||||
{ kind: "item", cost: "direct", key: "earth:3", name: "제근", sum: null },
|
||||
{ kind: "item", cost: "direct", key: "earth:4", name: "절토", sum: 100 },
|
||||
{ kind: "item", cost: "direct", key: "st:w", name: "옹벽", sum: null },
|
||||
{ kind: "item", cost: "owner", key: "mat:1", name: "레미콘", sum: null },
|
||||
] },
|
||||
gaps: [
|
||||
{ tab: "bill", key: "earth:3", name: "제근", why: "수량 연결 없음" },
|
||||
{ tab: "bill", key: "earth:3", name: "제근", why: "준비공 대가 — 연결 없음" },
|
||||
{ tab: "dg", key: "DG1", name: "찰쌓기", why: "대가 원천 없음" },
|
||||
{ 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]),
|
||||
rows: [1, 3].flatMap((n) =>
|
||||
(panel.list.children[n]?.children ?? []).map((r) => [r.dataset.i, r.className, r.text])),
|
||||
});
|
||||
const a = view();
|
||||
const rowsBox = panel.list.children[1];
|
||||
const blankBox = panel.list.children[1];
|
||||
const rowsBox = panel.list.children[3];
|
||||
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]);
|
||||
const tipBlank = [0, 1].map((n) => rowHover.text(blankBox.children[n]));
|
||||
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 }));
|
||||
console.log(JSON.stringify({ a, b, moves, tipRefresh, tipRow, tipBlank, selector: rowHover.selector,
|
||||
tipEmpty }));
|
||||
""",
|
||||
)
|
||||
a, b = out["a"], out["b"]
|
||||
assert a["pending"] and not b["pending"]
|
||||
assert a["texts"][0] == "연결 없음 2곳"
|
||||
# 빈 줄 = 직접공사 항목 중 금액 모름(관급 · 제목 · 아는 줄 빼고) · 같은 줄 까닭은 하나로
|
||||
assert a["texts"][0] == "빈 줄 2" and a["texts"][2] == "그 밖 확인 2곳"
|
||||
assert a["rows"] == [
|
||||
["0", "ui-sidebar-row", "찰쌓기일위대가표"],
|
||||
["1", "ui-sidebar-row", "모래재료비목록표"],
|
||||
["0", "ui-sidebar-row", "제근설계내역서"],
|
||||
["1", "ui-sidebar-row", "옹벽설계내역서"],
|
||||
["2", "ui-sidebar-row", "찰쌓기일위대가표"],
|
||||
["3", "ui-sidebar-row", "모래재료비목록표"],
|
||||
]
|
||||
assert a["texts"][2:] == ["B08 자재 집계표 못 읽음", "M01 에 없는 키 2 — EQ1 · LB2"]
|
||||
assert out["tipBlank"][0] == "수량 연결 없음 · 준비공 대가 — 연결 없음"
|
||||
assert out["tipBlank"][1].startswith("금액 모름") # 까닭 없는 빈 줄(이은 대가에 빈 줄)
|
||||
assert a["texts"][4:] == ["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 b["texts"] == ["빈 줄 0"] and b["rows"] == []
|
||||
assert out["tipEmpty"].startswith("단가 아직 안 굳힘") and "업데이트 필요" in out["tipEmpty"]
|
||||
|
||||
|
||||
|
||||
@@ -88,6 +88,7 @@ def test_B09_다음_저장_완료_인쇄(r):
|
||||
"toast:error",
|
||||
]
|
||||
assert b["prev"] == [False, []] # [<] = 공용 틀 몫
|
||||
assert b["blocked"] == [True, ["toast:warning"]] # 701-3 빈 줄 있으면 저장 · 완료 없이 머묾
|
||||
|
||||
|
||||
def test_B09_초기화(r):
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""701-1 — 석축 · 옹벽 길이 → B08 구조물 집계표 열 · B09 설계내역서 구조물 줄(owners 수량).
|
||||
|
||||
견본: 옹벽 H=2.0 구간 100~112(12 m) · 돌쌓기(찰) H=2.0 구간 196~204(옛 길이 칸 99 안 씀) ·
|
||||
돌쌓기(찰) 높이 빈칸(null · 금액 안 듦 = 값 빈칸) · 큰돌쌓기 사본 없음(안 셈 · 미연결)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from B08_Quantity import B08_Quantity_Router_Summary as summary
|
||||
from B09_Estimation.B09_Estimation_Bill_Items import structure_items
|
||||
from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition
|
||||
|
||||
|
||||
def _item(key: str) -> dict:
|
||||
return {"layer": "system", "owner": None, "key": key, "name": key}
|
||||
|
||||
|
||||
STRUCTURES = [
|
||||
{
|
||||
"structure_id": "s-wall",
|
||||
"type_id": "retaining_wall",
|
||||
"placement": "interval",
|
||||
"chainage_m": 106,
|
||||
"start_m": 100,
|
||||
"end_m": 112,
|
||||
"options": {"height_m": 2},
|
||||
"summary_item": _item("SS000201"),
|
||||
},
|
||||
{
|
||||
"structure_id": "s-wet",
|
||||
"type_id": "masonry_wet",
|
||||
"placement": "interval",
|
||||
"chainage_m": 200,
|
||||
"start_m": 196,
|
||||
"end_m": 204,
|
||||
"options": {"height_m": 2.0, "length_m": 99},
|
||||
"summary_item": _item("SS000202"),
|
||||
},
|
||||
{
|
||||
"structure_id": "s-wet-blank",
|
||||
"type_id": "masonry_wet",
|
||||
"placement": "interval",
|
||||
"chainage_m": 300,
|
||||
"start_m": 295,
|
||||
"end_m": 305,
|
||||
"options": {"height_m": None},
|
||||
"summary_item": _item("SS000202"),
|
||||
},
|
||||
{
|
||||
"structure_id": "s-boulder",
|
||||
"type_id": "boulder_masonry",
|
||||
"placement": "interval",
|
||||
"chainage_m": 400,
|
||||
"start_m": 395,
|
||||
"end_m": 405,
|
||||
"options": {"height_m": 3},
|
||||
"summary_item": _item("SS000203"),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def out(tmp_path: Path) -> dict:
|
||||
root = tmp_path / "proj"
|
||||
route = root / "B05_Profile/route"
|
||||
route.mkdir(parents=True)
|
||||
(route / "structures.json").write_text(
|
||||
json.dumps({"revision": 1, "structures": STRUCTURES}, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
names = definition.read()["종류"]["pipe"]["이름"]
|
||||
linked = {"s-wall": {}, "s-wet": {}, "s-wet-blank": {}} # 큰돌쌓기 = 사본 없음
|
||||
return summary.pipe_summary(root, [], names, 6.0, linked=linked)
|
||||
|
||||
|
||||
def _col(out: dict, cid: str) -> dict:
|
||||
return next(c for c in out["columns"] if c["id"] == cid)
|
||||
|
||||
|
||||
def test_옹벽_석축_높이마다_길이_열(out):
|
||||
wall = _col(out, "pipe:wall|retaining_wall|2.0")
|
||||
assert wall["group"] == ["옹벽", "H=2.0"] and wall["label"] == "m"
|
||||
assert wall["combos"] == ["system||SS000201"]
|
||||
assert wall["owners"] == {"s-wall": 12.0} # 구간 시작~종료
|
||||
wet = _col(out, "pipe:wall|masonry_wet|2.0")
|
||||
assert wet["owners"] == {"s-wet": 8.0} # 구간이 옛 길이 칸을 이김
|
||||
assert out["total"]["pipe:wall|retaining_wall|2.0"] == 12.0
|
||||
|
||||
|
||||
def test_빈_높이_미입력_열_값_빈칸(out):
|
||||
blank = _col(out, "pipe:wall|masonry_wet|-")
|
||||
assert blank["group"] == ["돌쌓기(찰)", "미입력"]
|
||||
assert blank["owners"] == {} and "pipe:wall|masonry_wet|-" not in out["total"]
|
||||
|
||||
|
||||
def test_사본_없는_벽_안_셈_미연결(out):
|
||||
assert not any("boulder_masonry" in c["id"] for c in out["columns"])
|
||||
assert [f["id"] for f in out["unlinked"]] == ["s-boulder"]
|
||||
assert {"retaining_wall", "masonry_wet", "boulder_masonry"} <= set(out["types"])
|
||||
|
||||
|
||||
def test_B09_구조물_줄_기타구조물_수량(out):
|
||||
rows = {r["key"]: r for r in structure_items(out)}
|
||||
wall = rows["st:pipe:wall|retaining_wall|2.0"]
|
||||
assert (wall["section"], wall["name"], wall["spec"], wall["unit"]) == (
|
||||
"2.3",
|
||||
"옹벽",
|
||||
"H=2.0",
|
||||
"m",
|
||||
)
|
||||
assert wall["qty"] == 12.0 and wall["owners"] == {"s-wall": 12.0}
|
||||
assert rows["st:pipe:wall|masonry_wet|-"]["qty"] is None
|
||||
|
||||
|
||||
def test_B09_금액_길이_곱하기_대가(out):
|
||||
from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation
|
||||
|
||||
price = {"no": "대가1", "key": "blk:w", "labor": 96868, "material": 11309, "expense": 16417}
|
||||
inputs = {"bill": {"items": structure_items(out)}}
|
||||
sheets = compute_estimation(inputs, {}, {"s-wall": price}.get)
|
||||
row = next(r for r in sheets["bill"]["rows"] if r["key"].endswith("#blk:w"))
|
||||
assert (row["qty"], row["sum"], row["note"]) == (12.0, 1495128, "대가1 · system||SS000201")
|
||||
@@ -0,0 +1,96 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""701-3 — 원가계산서 = 아는 금액까지 + 「빈 줄 n · 미완성」 띠 · 인쇄 · 확정은 빈 줄 0 일 때만.
|
||||
|
||||
- 설계내역서 `sums` = 빈 줄이 있어도 아는 비목 합 + `blank`(파이썬 · TS 짝 같은 값)
|
||||
- 총괄 · 원가계산서 = 그 합으로 셈(빈칸 아님)
|
||||
- [>] 확정 = 서버가 정본으로 다시 셈 · 빈 줄 있으면 409 · 단계 완료 안 함
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from test_145_3_b09_bill import INPUTS, USER, _run_ts # noqa: E402
|
||||
|
||||
from B09_Estimation import B09_Estimation_Router as router # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Calc_Total import compute_total # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Rates import rate_table # noqa: E402
|
||||
|
||||
|
||||
def test_빈_줄_있어도_아는_금액_합_거울():
|
||||
py = compute_estimation(INPUTS, USER)
|
||||
sums = py["bill"]["sums"]
|
||||
assert sums["blank"] == 2 and sums["labor"] == 7565300 and sums["material"] == 2771766
|
||||
assert _run_ts(INPUTS, USER)["bill"]["sums"] == sums
|
||||
|
||||
|
||||
def test_원가계산서_아는_금액으로_셈():
|
||||
sums = compute_estimation(INPUTS, USER)["bill"]["sums"]
|
||||
estimate = {**USER["estimate"], "estimated_price": 5e8, "duration_days": 120} # 요율 구간
|
||||
total = compute_total(sums, estimate, rate_table())
|
||||
assert total["sums"]["direct"] == 7565300 + 2771766 + 2627525
|
||||
assert total["cost"]["grand"] is not None and total["cost"]["grand"] > total["sums"]["direct"]
|
||||
|
||||
|
||||
def _complete(monkeypatch, blank: int) -> list[str]:
|
||||
marks: list[str] = []
|
||||
|
||||
async def root(_pid):
|
||||
return Path("x")
|
||||
|
||||
async def payload(_pid, _root):
|
||||
return {"sheets": {"bill": {"totals": {"direct": {"blank": blank}}}}}
|
||||
|
||||
async def mark(pid, stage, how):
|
||||
marks.append(f"{stage}:{how}")
|
||||
|
||||
monkeypatch.setattr(router, "_project_root", root)
|
||||
monkeypatch.setattr(router, "payload", payload)
|
||||
monkeypatch.setattr(router, "mark_stage", mark)
|
||||
asyncio.run(router.estimation_complete("P1"))
|
||||
return marks
|
||||
|
||||
|
||||
def test_확정_빈_줄_있으면_409_단계_그대로(monkeypatch):
|
||||
with pytest.raises(HTTPException) as caught:
|
||||
_complete(monkeypatch, 3)
|
||||
assert caught.value.status_code == 409 and "빈 줄 3 · 미완성" in caught.value.detail
|
||||
assert _complete(monkeypatch, 0) == ["6:next"]
|
||||
|
||||
|
||||
def test_띠_글_빈_줄_0_이면_없음():
|
||||
source = (ROOT / "B09_Estimation/B09_Estimation_Calc_Base.ts").read_text(encoding="utf-8")
|
||||
tabs = (ROOT / "B09_Estimation/B09_Estimation_UI_Tabs.ts").read_text(encoding="utf-8")
|
||||
start = tabs.index("export const blankRows")
|
||||
end = tabs.index("\n};\n", tabs.index("export const unfinished")) + 4
|
||||
rows = [
|
||||
{"kind": "item", "cost": "direct", "sum": None},
|
||||
{"kind": "item", "cost": "direct", "sum": 5},
|
||||
]
|
||||
script = (
|
||||
"type Row = Record<string, any>;\n"
|
||||
+ tabs[start:end]
|
||||
+ f"\nconsole.log(JSON.stringify([unfinished({{ bill: {{ rows: {json.dumps(rows)} }} }}),"
|
||||
" unfinished({ bill: { rows: [] } })]));"
|
||||
)
|
||||
assert "export type Row" in source # 셈 짝 Row 꼴 그대로(흉내 타입)
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", script],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
banner, none = json.loads(result.stdout)
|
||||
assert banner.startswith("빈 줄 1 · 미완성") and "인쇄 · 확정은 빈 줄 0 일 때만" in banner
|
||||
assert none is None
|
||||
@@ -9,6 +9,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from resources.tester import test_m02_tree as base
|
||||
@@ -158,3 +159,27 @@ def test_항목_추가_창_카드_격자_표_접기():
|
||||
assert "max-height: calc(100dvh - var(--app-header-h));\n overflow-y: auto;" in css
|
||||
main = (m02 / "M02_MasterTemplete_UI_Main.ts").read_text("utf-8")
|
||||
assert "host.hidden = !host.hidden;" in main and 'title.classList.toggle("is-folded"' in main
|
||||
|
||||
|
||||
def test_복사_생성_이름_원래_이름_복사_같은_이름_둘_없음(world):
|
||||
"""701-4 — 복사 생성(대상 `복사이름`) = 「원래 이름 복사」 · 또 하면 「… 복사(1)」 · 그냥 붙여넣기는 그대로."""
|
||||
company = _company(world)
|
||||
wall = company.item(["옹벽"])["key"]
|
||||
client = _as(world)
|
||||
body = {
|
||||
"from_layer": "company",
|
||||
"대상": [{"경로": [], "키": wall, "복사이름": True}],
|
||||
"경로": [],
|
||||
}
|
||||
first = client.post(PASTE.format("personal"), json=body).json()
|
||||
second = client.post(PASTE.format("personal"), json=body).json()
|
||||
plain = client.post(PASTE.format("personal"), json={**body, "대상": [{"경로": [], "키": wall}]})
|
||||
names = [r["경로"][-1] for r in plain.json()["items"]]
|
||||
assert sorted(names) == ["옹벽", "옹벽 복사", "옹벽 복사(1)"]
|
||||
assert first["알림"]["바뀐이름"] == ["옹벽 복사"] and second["알림"]["바뀐이름"] == [
|
||||
"옹벽 복사(1)"
|
||||
]
|
||||
assert len(set(names)) == len(names) # 같은 이름 둘 없음
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
ui = (root / "M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts").read_text("utf-8")
|
||||
assert "refs: [{ 경로: [], 키: key, 복사이름: true }]" in ui # 복사 생성 길만
|
||||
|
||||
Reference in New Issue
Block a user