From 80bae255bd64c50015ac4b721edbf813849080f4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 12:31:45 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix(B08=C2=B7B09):=20=EC=84=9D=EC=B6=95=20?= =?UTF-8?q?=C2=B7=20=EC=98=B9=EB=B2=BD=20=EA=B8=B8=EC=9D=B4=20=E2=86=92=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=A7=91=EA=B3=84=ED=91=9C=20?= =?UTF-8?q?=EC=97=B4=20=C2=B7=20=EC=84=A4=EA=B3=84=EB=82=B4=EC=97=AD?= =?UTF-8?q?=EC=84=9C=20=EA=B3=B1=ED=95=98=EA=B8=B0(PLAN=20701-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B08 구조물 집계표 — 구조물 표 형상 wall(옹벽 · 돌쌓기 · 큰돌쌓기 · 흙막이) 높이마다 길이 m 열 · 빈 높이 = 「미입력」 열 값 빈칸 · 사본 없으면 안 셈(미연결) - 응답 types 에 벽 종류 — 화면 조합 열에서 뺌 - B09 설계내역서 — 벽 열 = 2.3 기타구조물 · owners 수량 × 대가 - test_120 종류 집합 낡은 값 갱신 · 시험 test_701_1 Co-Authored-By: Claude Opus 5.5 --- B08_Quantity/B08_Quantity_Router_Summary.py | 34 ++++- B09_Estimation/B09_Estimation_Bill_Items.py | 4 +- .../tester/test_120_b08_drain_summary.py | 1 + .../tester/test_701_1_b08_wall_summary.py | 128 ++++++++++++++++++ 4 files changed, 164 insertions(+), 3 deletions(-) create mode 100644 resources/tester/test_701_1_b08_wall_summary.py diff --git a/B08_Quantity/B08_Quantity_Router_Summary.py b/B08_Quantity/B08_Quantity_Router_Summary.py index 20b13dcf9..23afc7b27 100644 --- a/B08_Quantity/B08_Quantity_Router_Summary.py +++ b/B08_Quantity/B08_Quantity_Router_Summary.py @@ -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) diff --git a/B09_Estimation/B09_Estimation_Bill_Items.py b/B09_Estimation/B09_Estimation_Bill_Items.py index 9282a4e92..f9b4fa725 100644 --- a/B09_Estimation/B09_Estimation_Bill_Items.py +++ b/B09_Estimation/B09_Estimation_Bill_Items.py @@ -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|",) diff --git a/resources/tester/test_120_b08_drain_summary.py b/resources/tester/test_120_b08_drain_summary.py index 870c4e11d..a3effb254 100644 --- a/resources/tester/test_120_b08_drain_summary.py +++ b/resources/tester/test_120_b08_drain_summary.py @@ -245,6 +245,7 @@ def test_차례_계_종류(out): "cross_drain_exposed", "open_ditch", "cross_underdrain", + *summary.WALL_TYPES, # 석축 · 옹벽 길이 열(701-1) } diff --git a/resources/tester/test_701_1_b08_wall_summary.py b/resources/tester/test_701_1_b08_wall_summary.py new file mode 100644 index 000000000..c8723be4b --- /dev/null +++ b/resources/tester/test_701_1_b08_wall_summary.py @@ -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") From 21bc4f856ae530ccb9a9a793dea1a950cc227e8e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 12:51:05 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat(B09):=20=EC=B2=AB=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=20=3D=20=EC=84=A4=EA=B3=84=EB=82=B4=EC=97=AD=EC=84=9C=20=C2=B7?= =?UTF-8?q?=20=EB=B9=88=20=EC=A4=84=20=EC=88=98=20=ED=95=98=EB=82=98(PLAN?= =?UTF-8?q?=20701-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 첫 탭 = 설계내역서(빈 원가계산서 아님) · 엑셀 탭 차례는 그대로 - 빈 줄 = 설계내역서 금액 모르는 직접공사 항목 줄(blankRows) — 설계내역서 안내 · 데이터 점검 · 집계표 넷이 같은 수 · 같은 낱말 - 데이터 점검 = 「빈 줄 n」(줄마다 하나 · 까닭 모음) + 「그 밖 확인 n곳」(호표 · 재료 · 탭 · 키마다 하나) - test_203_1 낡은 값(첫 탭 · 「연결 없음 n곳」) 갱신 · helper 에 593-1 시각 글 stub(dateTimeText 없어 깨져 있던 것) Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_UI_Check.ts | 84 +++++++++++++------ B09_Estimation/B09_Estimation_UI_Shell.ts | 11 +-- B09_Estimation/B09_Estimation_UI_Tabs.ts | 6 ++ .../B09_Estimation_UI_Tabs_Summary.ts | 17 ++-- resources/tester/helper_b09_group_cards.py | 1 + resources/tester/test_203_1_b09_data_check.py | 55 ++++++++---- 6 files changed, 115 insertions(+), 59 deletions(-) diff --git a/B09_Estimation/B09_Estimation_UI_Check.ts b/B09_Estimation/B09_Estimation_UI_Check.ts index 285941732..07ce77bb7 100644 --- a/B09_Estimation/B09_Estimation_UI_Check.ts +++ b/B09_Estimation/B09_Estimation_UI_Check.ts @@ -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(); + const order: string[] = []; + const named = new Map(); + 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 }; } diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 4eeb511d8..032a028ae 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -38,14 +38,7 @@ import { createCheckPanel } from "./B09_Estimation_UI_Check"; import { tableOptionsOf, tabTableOf } from "./B09_Estimation_UI_Builders"; import { createDock, linkMode } from "./B09_Estimation_UI_Dock"; import { closePopups, openRowPopup } from "./B09_Estimation_UI_Popup"; -import { - DRAFT, - rowIndex, - TABS, - type Link, - type TabContext, - type TabId, -} from "./B09_Estimation_UI_Tabs"; +import { DRAFT, rowIndex, type Link, type TabContext, type TabId } from "./B09_Estimation_UI_Tabs"; import { attachRowMenu, openMenuAt, type BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit"; import { jumpRange, jumpTo, levelBar, pickedClass } from "./B09_Estimation_UI_Tabs_Bill_Level"; import { @@ -126,7 +119,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)); diff --git a/B09_Estimation/B09_Estimation_UI_Tabs.ts b/B09_Estimation/B09_Estimation_UI_Tabs.ts index 1adb10aad..d37e2ff84 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs.ts @@ -56,6 +56,12 @@ 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, + ); + /** 위 탭(181-1) — [위 탭 이름, 아래 탭들] · 아래 탭 하나뿐 = 위 탭 단독 · 첫 아래 탭 = 위 탭 키 */ export const TAB_GROUPS: Array<[string, TabId[]]> = [ ["공사원가계산서", ["cost"]], diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts index f3cef1dd2..6bbc83213 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Summary.ts @@ -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), }; }; diff --git a/resources/tester/helper_b09_group_cards.py b/resources/tester/helper_b09_group_cards.py index 532b80751..2e74ef513 100644 --- a/resources/tester/helper_b09_group_cards.py +++ b/resources/tester/helper_b09_group_cards.py @@ -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) => { diff --git a/resources/tester/test_203_1_b09_data_check.py b/resources/tester/test_203_1_b09_data_check.py index 5e63d693e..10103fa8a 100644 --- a/resources/tester/test_203_1_b09_data_check.py +++ b/resources/tester/test_203_1_b09_data_check.py @@ -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"] From df6bc10237282fc135d8156876263579f3c77375 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 13:09:29 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat(B09):=20=EC=9B=90=EA=B0=80=EA=B3=84?= =?UTF-8?q?=EC=82=B0=EC=84=9C=20=3D=20=EC=95=84=EB=8A=94=20=EA=B8=88?= =?UTF-8?q?=EC=95=A1=EA=B9=8C=EC=A7=80=20+=20=E3=80=8C=EB=B9=88=20?= =?UTF-8?q?=EC=A4=84=20n=20=C2=B7=20=EB=AF=B8=EC=99=84=EC=84=B1=E3=80=8D?= =?UTF-8?q?=20=EB=9D=A0=20=C2=B7=20=EC=9D=B8=EC=87=84=20=C2=B7=20=ED=99=95?= =?UTF-8?q?=EC=A0=95=EC=9D=80=20=EB=B9=88=20=EC=A4=84=200=20=EC=9D=BC=20?= =?UTF-8?q?=EB=95=8C=EB=A7=8C(PLAN=20701-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 설계내역서 sums = 빈 줄이 있어도 아는 비목 합 + blank(파이썬 · TS 짝 · 관급은 그대로) - 총괄 · 원가계산서 표 위 공용 띠(경고) — 탭 표 banner 칸 - [인쇄 미리보기] · [>] = 빈 줄 있으면 알림 뒤 머묾(DRAFT.blocked) · 서버 확정(complete) = 정본으로 다시 셈 · 빈 줄 있으면 409 - 설계내역서 안내 글 줄 고침 · 낡은 시험(sums None · 패널 줄 · [>] 흉내) 갱신 · 시험 test_701_3 Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_Calc_Bill.py | 13 +-- B09_Estimation/B09_Estimation_Calc_Bill.ts | 13 +-- B09_Estimation/B09_Estimation_Router.py | 8 +- B09_Estimation/B09_Estimation_UI_Shell.ts | 17 +++- B09_Estimation/B09_Estimation_UI_Tabs.ts | 10 ++ B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts | 2 +- .../B09_Estimation_UI_Tabs_Total.ts | 3 + B09_Estimation/draft/B09_Draft_Print_UI.ts | 11 ++- .../tester/helper_298_2_b08_b09_buttons.cjs | 5 + resources/tester/test_145_3_b09_bill.py | 11 +-- .../tester/test_202_1_table_hint_bottom.py | 2 +- .../tester/test_298_2_b08_b09_buttons.py | 1 + .../tester/test_701_3_b09_cost_unfinished.py | 96 +++++++++++++++++++ 13 files changed, 163 insertions(+), 29 deletions(-) create mode 100644 resources/tester/test_701_3_b09_cost_unfinished.py diff --git a/B09_Estimation/B09_Estimation_Calc_Bill.py b/B09_Estimation/B09_Estimation_Calc_Bill.py index 6c7379b47..50a783f96 100644 --- a/B09_Estimation/B09_Estimation_Calc_Bill.py +++ b/B09_Estimation/B09_Estimation_Calc_Bill.py @@ -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} diff --git a/B09_Estimation/B09_Estimation_Calc_Bill.ts b/B09_Estimation/B09_Estimation_Calc_Bill.ts index 50ad3e3fd..3edec67e9 100644 --- a/B09_Estimation/B09_Estimation_Calc_Bill.ts +++ b/B09_Estimation/B09_Estimation_Calc_Bill.ts @@ -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 }; } diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 841f6caa3..77f62f2be 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -230,8 +230,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)} diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 032a028ae..e7f90edc7 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -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, @@ -38,7 +39,14 @@ import { createCheckPanel } from "./B09_Estimation_UI_Check"; import { tableOptionsOf, tabTableOf } from "./B09_Estimation_UI_Builders"; import { createDock, linkMode } from "./B09_Estimation_UI_Dock"; import { closePopups, openRowPopup } from "./B09_Estimation_UI_Popup"; -import { DRAFT, rowIndex, type Link, type TabContext, type TabId } from "./B09_Estimation_UI_Tabs"; +import { + DRAFT, + rowIndex, + unfinished, + type Link, + type TabContext, + type TabId, +} from "./B09_Estimation_UI_Tabs"; import { attachRowMenu, openMenuAt, type BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit"; import { jumpRange, jumpTo, levelBar, pickedClass } from "./B09_Estimation_UI_Tabs_Bill_Level"; import { @@ -257,7 +265,9 @@ export function renderB09Estimation(root: HTMLElement): void { // 203 안내 글 영역 하나 — 설계내역서 레벨 안내 + 탭 안내 이어 붙임 if (level && hint) hint.textContent = `${level.guide.textContent} · ${hint.textContent}`; const foot = hint ?? level?.guide; - panel.replaceChildren(table.root, ...(foot ? [foot] : [])); + // 701-3 띠(원가계산서 「빈 줄 n · 미완성」) = 표 위 + const banner = spec.banner ? [createBanner({ tone: "warning", text: spec.banner })] : []; + panel.replaceChildren(...banner, table.root, ...(foot ? [foot] : [])); if (at >= 0) requestAnimationFrame(() => table.focusRow(at, false)); // 스크롤만(표시 = 빨강) }; @@ -302,6 +312,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", @@ -361,6 +372,8 @@ export function renderB09Estimation(root: HTMLElement): void { left.addEventListener("ui-step-nav", (event) => { if ((event as CustomEvent).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 }, { diff --git a/B09_Estimation/B09_Estimation_UI_Tabs.ts b/B09_Estimation/B09_Estimation_UI_Tabs.ts index d37e2ff84..7a116957a 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs.ts @@ -62,6 +62,12 @@ export const blankRows = (sheets: Row): Row[] => (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"]], @@ -101,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; @@ -140,6 +148,8 @@ export const DRAFT: { next?: () => void; /** 고친 값 저장(셸 `saveChanged`) — 성공이면 참 · 초안 단추가 이동 전에 부름. */ save?: () => Promise; + /** 인쇄 · 확정 막는 까닭(셸 · 701-3 빈 줄 있으면 글) — 없으면 null */ + blocked?: () => string | null; } = { builders: {}, extend: [], panels: [], actions: [] }; /** 201-1 호표 묶음 레벨(보기 전용) — 머리 · 합계(제목 줄) = 1 · 그 밖 = 2 · key 열을 들여씀 */ diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts index 7ae93caf2..1c0065b39 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts @@ -68,7 +68,7 @@ export const buildBill: TabBuild = (ctx): BillTable => { hint: "줄을 우클릭하면 항목 추가 · 줄 삭제 · 옮김 · 단가연결 칸 램프를 누르면 일위대가 · 단가산출 연결" + (direct.blank - ? ` · 빈 줄 ${direct.blank} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음` + ? ` · 빈 줄 ${direct.blank} — 계는 아는 금액만 · 원가계산서도 아는 금액까지 · 인쇄 · 확정은 빈 줄 0 일 때만` : ""), editOnDouble: true, editable: (i, key) => { diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts index e41a21e1b..1fb329ba6 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts @@ -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), diff --git a/B09_Estimation/draft/B09_Draft_Print_UI.ts b/B09_Estimation/draft/B09_Draft_Print_UI.ts index 10508c02b..db8ee9f9c 100644 --- a/B09_Estimation/draft/B09_Draft_Print_UI.ts +++ b/B09_Estimation/draft/B09_Draft_Print_UI.ts @@ -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; diff --git a/resources/tester/helper_298_2_b08_b09_buttons.cjs b/resources/tester/helper_298_2_b08_b09_buttons.cjs index 63e5bee72..35a9518d4 100644 --- a/resources/tester/helper_298_2_b08_b09_buttons.cjs +++ b/resources/tester/helper_298_2_b08_b09_buttons.cjs @@ -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 () => { diff --git a/resources/tester/test_145_3_b09_bill.py b/resources/tester/test_145_3_b09_bill.py index 1113830dd..f18b74948 100644 --- a/resources/tester/test_145_3_b09_bill.py +++ b/resources/tester/test_145_3_b09_bill.py @@ -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 diff --git a/resources/tester/test_202_1_table_hint_bottom.py b/resources/tester/test_202_1_table_hint_bottom.py index 8b75244ec..7d33b35ce 100644 --- a/resources/tester/test_202_1_table_hint_bottom.py +++ b/resources/tester/test_202_1_table_hint_bottom.py @@ -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) diff --git a/resources/tester/test_298_2_b08_b09_buttons.py b/resources/tester/test_298_2_b08_b09_buttons.py index 64f2735fc..6d498e0e5 100644 --- a/resources/tester/test_298_2_b08_b09_buttons.py +++ b/resources/tester/test_298_2_b08_b09_buttons.py @@ -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): diff --git a/resources/tester/test_701_3_b09_cost_unfinished.py b/resources/tester/test_701_3_b09_cost_unfinished.py new file mode 100644 index 000000000..fdc18a385 --- /dev/null +++ b/resources/tester/test_701_3_b09_cost_unfinished.py @@ -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;\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 From 9246250629df2f20008b27f16658b628eaf430c4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 13:22:51 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat(M02):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EC=88=98=EB=9F=89=20=EC=95=88=20=EB=84=A3=EC=9D=80?= =?UTF-8?q?=20=EC=A4=84=20=EA=B2=BD=EA=B3=A0=20=C2=B7=20=EB=B3=B5=EC=82=AC?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=20=EC=9D=B4=EB=A6=84=20=E3=80=8C=E2=80=A6?= =?UTF-8?q?=20=EB=B3=B5=EC=82=AC=E3=80=8D(PLAN=20701-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 일위대가 카드 · 상세 합계 줄 — 수량산출 빈 줄 수 「수량 안 넣은 줄 n — 합계에서 빠짐」(좁은 카드에서 제 줄 · 줄바꿈) - 복사 생성(나무 copyIn) = 대상 복사이름 → 새 이름 「원래 이름 복사」 · 또 하면 「… 복사(1)」 · 그냥 붙여넣기는 그대로 - 시험 test_m02_tree_paste(임시 storage) 한 건 더함 Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Item_Paste.py | 6 +++-- .../M02_MasterTemplete_Api_Fetch.ts | 2 ++ .../M02_MasterTemplete_Labor.css | 7 ++++++ .../M02_MasterTemplete_Labor_Rows.ts | 23 ++++++++++++++++- .../M02_MasterTemplete_Labor_Text.ts | 4 +++ .../M02_MasterTemplete_TreePanel.ts | 7 +++++- resources/tester/test_m02_tree_paste.py | 25 +++++++++++++++++++ 7 files changed, 70 insertions(+), 4 deletions(-) diff --git a/M02_MasterTemplete/M02_Item_Paste.py b/M02_MasterTemplete/M02_Item_Paste.py index e906dffb6..081eb808f 100644 --- a/M02_MasterTemplete/M02_Item_Paste.py +++ b/M02_MasterTemplete/M02_Item_Paste.py @@ -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 diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 7c6f6e109..6da5621f7 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -467,6 +467,8 @@ export const editTree = ( export interface TreeRef { 경로: string[]; 키?: string; + /** 복사 생성(701-4) — 새 이름 = 「원래 이름 복사」(같은 이름 둘 안 생김) */ + 복사이름?: boolean; } /** 복사해 둔 것 — 나무 종류(`kind` · 구조물 · 일위대가) · 복사한 층 · 고른 것들 */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.css b/M02_MasterTemplete/M02_MasterTemplete_Labor.css index 1c32aadc0..083b9ee65 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.css @@ -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; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index d7638b889..a48088c51 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -493,6 +493,13 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement }); const sum = (part: Partial | 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: "" }), diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index e5b3b3ce6..807f67756 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -96,6 +96,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}", diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts index 259dc409c..384e588c5 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts @@ -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, + ), ); }; diff --git a/resources/tester/test_m02_tree_paste.py b/resources/tester/test_m02_tree_paste.py index 757936cb8..3c19c8dde 100644 --- a/resources/tester/test_m02_tree_paste.py +++ b/resources/tester/test_m02_tree_paste.py @@ -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 # 복사 생성 길만