feat(B09 설계내역서 펼침): 우클릭 복사해 새 일위대가 · 내 자료집에 저장 · 어디 쓰였나 (PLAN 703-2)

- 복사 = 덮개 K:… 의 @copy {from · name · spec} + 원본 덮개째(B09 사용자 값 · 브레인 정함) — 셈 짝 Calc_Labor py·ts 가 원본 블록을 본뜬 새 호표(번호 끝 · 기본값 = 원본) · 이 줄 단가연결을 새 호표로 바꿀지 물음 · 복사본 지우기(잇는 줄 있으면 막음)
- 원본 없음 = 짝 잃음 알림 · 저장 다듬기 @copy 글만 · 서고 사본 원본 = prune 에서 지킴
- 내 자료집 = POST …/estimation/daega/personal → 개인 층 사전조합(B09_Estimation_Daega_Personal) — 넣을 값 · 고른 자재 · 줄 수량 덮개(손) 담음 · 품 · 단가 덮개 = 안 담은 수 알림 · 같은 이름 409 → 덮어쓰기 · 새 이름
- 어디 쓰였나 = 누른 줄 노임 · 자재 · 중기(그 밖 = 호표)를 쓰는 내역 줄 목록 → 누르면 그 줄로 · 펼침
- 새 파일 B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts · 시험 test_703_b09_daega_copy(거울 · 임시 storage 길)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 13:35:44 +09:00
co-authored by Claude Opus 5.5
parent 595d6415eb
commit 1c1dc9c523
9 changed files with 693 additions and 14 deletions
@@ -341,12 +341,18 @@ def copies(project_root: str | Path) -> list[tuple[Path, dict[str, Any]]]:
def used_folders(user: dict[str, Any]) -> set[str]:
"""설계내역서 덮개 줄 · 거리이정표 운반 연결(251-5)이 잇는 서고 사본 폴더."""
"""설계내역서 덮개 줄 · 거리이정표 운반 연결(251-5)이 잇는 서고 사본 폴더 +
일위대가 복사본(703-2 `@copy`)의 원본 사본."""
copies = [
str(lines["@copy"].get("from") or "")
for lines in (user.get("daega_cover") or {}).values()
if isinstance(lines, dict) and isinstance(lines.get("@copy"), dict)
]
return {
str(ref["lib"])
for row in ref_rows(user)
if isinstance(ref := row.get("ref"), dict) and ref.get("tab") == "dg" and ref.get("lib")
}
} | {src[2:] for src in copies if src.startswith("L:")}
def prune(project_root: str | Path, user: dict[str, Any]) -> list[str]:
+20 -1
View File
@@ -19,6 +19,8 @@
고친 칸 덮개(702-2) = 사용자 값 `daega_cover` {블록 cover: {줄 cid: {qty · 노 · 재 · 경 단가}}} —
있는 칸만 셈 값 대신 · 줄 `base`(품셈 값 = 셈 수량 · 단가) · `covered`(고친 칸) ·
짝 잃은 덮개(그 블록 · 줄 없음) = `daega_orphans` [{cover, cid}] — 지우지 않고 알림만.
복사본(703-2) = 덮개 `K:…` 의 `@copy` {from · name · spec} — 원본 cover 블록을 본뜬 새 호표
(id `DG:K:…` · 번호 = 끝 · 줄 · 기본값 = 원본 · 덮개 = 제 것) · 원본 없음 = 짝 잃음 {cover, cid: @copy}.
"""
from __future__ import annotations
@@ -30,6 +32,7 @@ from B09_Estimation.B09_Estimation_Calc_Base import SUPPLY, gap, num, rounddown,
PARTS = ("labor", "material", "expense")
COVER_KEYS = ("qty", *PARTS)
TAB = "dg"
COPY = "@copy"
WHY = {
"labor": "노임 단가 없음",
"material": "자재 적용 단가 없음",
@@ -142,6 +145,21 @@ def _block(
}
def with_copies(blocks: list[dict[str, Any]], cover: dict[str, Any]) -> list[dict[str, Any]]:
"""복사본(703-2) — 덮개 차례대로 `@copy` 마다 원본 블록을 본떠 끝에 더함(복사본의 복사본 됨)."""
out = list(blocks)
for key, lines in cover.items():
meta = (lines or {}).get(COPY)
if not isinstance(meta, dict):
continue
src = next((b for b in out if b.get("cover") == meta.get("from")), None)
if src is not None:
spec = meta["spec"] if "spec" in meta else src["spec"]
name = meta.get("name") or src["name"]
out.append({**src, "id": f"DG:{key}", "cover": key, "name": name, "spec": spec})
return out
def compute_labor(
daega: dict[str, Any] | None,
sheets: dict[str, Any],
@@ -161,6 +179,7 @@ def compute_labor(
"sangeun": sangeun or {},
}
cover = cover or {}
blocks = with_copies(blocks, cover)
by_id = {b["id"]: b for b in blocks}
no = {b["id"]: n for n, b in enumerate(blocks, 1)}
done: dict[str, dict[str, Any]] = {}
@@ -211,7 +230,7 @@ def compute_labor(
{"cover": key, "cid": cid}
for key, lines in cover.items()
for cid in lines
if cid not in cids.get(key, ())
if cid not in cids.get(key, ()) and not (cid == COPY and f"DG:{key}" in by_id)
]
return {
"daega_blocks": out_blocks,
+20 -2
View File
@@ -15,6 +15,8 @@
* 빈 줄(166-6) = 블록 합 = 아는 줄 합 + blank n · blank_names — 내역 단가 · 부른 대가로는 안 넘김
* 고친 칸 덮개(702-2) = 사용자 값 daega_cover {블록 cover: {줄 cid: {qty · labor · material · expense}}} —
* 있는 칸만 셈 값 대신 · 줄 base(품셈 값) · covered(고친 칸) · 짝 잃은 덮개 = daega_orphans(알림만)
* 복사본(703-2) = 덮개 K:… 의 @copy {from · name · spec} — 원본 cover 블록을 본뜬 새 호표
* (id DG:K:… · 번호 = 끝 · 줄 · 기본값 = 원본 · 덮개 = 제 것) · 원본 없음 = 짝 잃음 {cover, cid: @copy}
* ========================================================================== */
import {
@@ -31,6 +33,7 @@ import {
const PARTS = ["labor", "material", "expense"] as const;
const COVER_KEYS = ["qty", ...PARTS] as const;
const TAB = "dg";
export const COPY = "@copy";
const WHY: Record<string, string> = {
labor: "노임 단가 없음",
material: "자재 적용 단가 없음",
@@ -146,6 +149,20 @@ function block(b: Row, books: Row, sums: Record<string, Row>, gaps: Gap[], own:
};
}
/** 복사본(703-2) — 덮개 차례대로 @copy 마다 원본 블록을 본떠 끝에 더함(복사본의 복사본 됨) */
function withCopies(blocks: Row[], covers: Row): Row[] {
const out = [...blocks];
for (const [key, lines] of Object.entries(covers)) {
const meta: Row | undefined = (lines || {})[COPY];
if (!meta || typeof meta !== "object") continue;
const src = out.find((b) => b.cover === meta.from);
if (!src) continue;
const spec = "spec" in meta ? meta.spec : src.spec;
out.push({ ...src, id: `DG:${key}`, cover: key, name: meta.name || src.name, spec });
}
return out;
}
/** 대가 탭 둘 — daega_blocks(일위대가표) · daega(목록표) · daega_owners + 바탕 gaps 이어 붙임 */
export function computeLabor(
daega: Row | null | undefined,
@@ -153,8 +170,8 @@ export function computeLabor(
sangeun: Row = {},
covers: Row = {},
): Row {
const blocks: Row[] = (daega || {}).blocks || [];
covers = covers || {};
const blocks: Row[] = withCopies((daega || {}).blocks || [], covers);
const index = (rows: Row[] | undefined): Row =>
Object.fromEntries((rows || []).map((r) => [r.key, r]));
const books: Row = {
@@ -207,7 +224,8 @@ export function computeLabor(
const orphans: Row[] = [];
for (const [key, lines] of Object.entries(covers))
for (const cid of Object.keys(lines || {}))
if (!cids.get(key)?.has(cid)) orphans.push({ cover: key, cid });
if (!cids.get(key)?.has(cid) && !(cid === COPY && `DG:${key}` in byId))
orphans.push({ cover: key, cid });
return {
daega_blocks: outBlocks,
daega: listing,
@@ -0,0 +1,165 @@
"""B09 「내 자료집에 저장」(PLAN 703-2) — 펼친 일위대가(대가 블록) → 개인 층 사전조합(SC).
담는 것 = 담은로직(넣을 값 · 고른 자재 얹음) · 자재 · 줄 수량(= 수량산출 수기 `손`) —
맨 위 줄 수량 덮개 = 손(원래 손 × 덮개 ÷ 블록 줄 수량 · 대가 줄 ÷ 배를 되돌림)
안 담는 것 = 품 덮개(부른 블록 안 줄 · 품셈 블록 줄 — 로직 통째 배수로 바꾸면 다른 줄까지 바뀜) ·
단가 덮개(사전조합 꼴에 단가 칸 없음) → 수만 알림(품은 PLAN 705 고정 줄이 생기면 담김)
원천 = 블록 cover — 조합 `S:`/`L:` = 사본 폴더(일위대가 · 넣을 값 · 고른 자재 · 셈 수량) ·
사전조합 `C:`/`L:` = 굳힌 참조사본 문서 · 품셈 `P:` = 로직 한 줄 ·
복사본 `K:`(703-2) = 원본까지 따라감(이름 · 덮개 = 복사본 것)
같은 이름 = 409 `{같은이름: 키, 이름}` → 부르는 쪽이 덮어쓰기(`overwrite` = 그 키) · 새 이름 ·
쓰기 = 지금 층(`store.at` · 층 권한 · 폴더는 부르는 쪽).
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from B09_Estimation import B09_Estimation_Bill_Library as library
from B09_Estimation.B09_Estimation_Calc_Labor import COPY, with_copies
from B09_Estimation.B09_Estimation_Labor_Source import AREA_FILE, daega_inputs
from M02_MasterTemplete import M02_Item_Prices as item_prices
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete.M02_Item_Snapshot import (
MANIFEST_NAME,
PICKS_FILE,
VALUES_FILE,
structures_dir,
)
from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, PRICE_FILE, read_json
KIND = "precombo"
PRICE_KEYS = ("labor", "material", "expense")
def _combo(folder: Path) -> tuple[list[dict], list[dict]]:
"""구조물조합 사본 → (담은로직 · 자재) — 넣을 값 · 고른 자재 얹고 수량 = 셈 수량(손)."""
labor = read_json(folder / LABOR_FILE) or {}
values = read_json(folder / VALUES_FILE) or {}
picks = read_json(folder / PICKS_FILE) or {}
got = item_prices.calc(
labor, read_json(folder / PRICE_FILE) or {}, values, picks, read_json(folder / AREA_FILE)
)
doc = item_prices.with_values(labor.get("줄") or {}, values, picks)
lines = [
{**one, "수량": {"손": done.get("수량")}}
for one, done in zip(doc.get("담은로직") or [], got.get("줄") or [])
]
mats = [
{**one, "수량": {"손": done.get("수량")}}
for one, done in zip(doc.get("자재") or [], got.get("자재") or [])
]
return lines, mats
def _frozen(root: Path, rid: str) -> dict[str, Any] | None:
"""굳힌 사전조합 문서 — 구조물 사본 · 서고 사본의 `참조사본` 에서 찾음."""
folders = [p for p in sorted(structures_dir(root).glob("*")) if p.is_dir()]
folders += [p for p, _m in library.copies(root)]
for folder in folders:
doc = ((read_json(folder / LABOR_FILE) or {}).get("참조사본") or {}).get(rid)
if isinstance(doc, dict):
return doc
return None
def _precombo(doc: dict[str, Any]) -> tuple[list[dict], list[dict]]:
lines = [
{**one, "수량": {"손": (one.get("수량") or {}).get("손", 1)}}
for one in doc.get("담은로직") or []
if isinstance(one, dict)
]
return lines, [m for m in doc.get("자재") or [] if isinstance(m, dict)]
def source(root: Path, block: dict[str, Any], base: str) -> tuple[list[dict], list[dict]]:
"""블록 원천(`base` = 복사본이면 원본 cover) → (담은로직 · 자재) · 못 찾으면 LookupError."""
head, _, rest = base.partition(":")
if block["kind"] == "품셈":
origin = block.get("origin") or {}
one = {"로직": origin.get("로직"), "입력값": origin.get("입력값") or {}}
one.update({k: origin[k] for k in ("켜쌓기율", "고름") if origin.get(k) is not None})
return [{**one, "수량": {"손": 1}}], []
if head == "S":
return _combo(structures_dir(root) / rest)
if head == "L":
folder = library.library_dir(root) / rest
manifest = read_json(folder / MANIFEST_NAME) or {}
if manifest.get("종류") != "precombo":
return _combo(folder)
ref = (manifest.get("원본") or {}).get("참조")
doc = ((read_json(folder / LABOR_FILE) or {}).get("참조사본") or {}).get(refs.ref_id(ref))
if isinstance(doc, dict):
return _precombo(doc)
if head == "C" and (doc := _frozen(root, rest)) is not None:
return _precombo(doc)
raise LookupError(f"일위대가 원천을 못 찾음 「{base}」")
def build(root: Path, cover_key: str, cover: dict[str, Any], name: str | None = None) -> dict:
"""블록 cover → `{문서, 담음, 안담음}` — 문서 = 사전조합 꼴(키 · 층 쓰기 전)."""
raw = daega_inputs(root).get("blocks") or []
blocks = {b["cover"]: b for b in with_copies(raw, cover)}
block = blocks.get(cover_key)
if block is None:
raise LookupError(f"없는 일위대가 「{cover_key}」")
base = cover_key
while base.startswith("K:"): # 복사본 → 원본 cover
base = str(((cover.get(base) or {}).get(COPY) or {}).get("from") or "")
lines, mats = source(root, blocks.get(base, block), base)
mine = cover.get(cover_key) or {}
kept = {"수량": 0, "넣을값": sum(bool(ln.get("입력값")) for ln in lines)}
kept["고른자재"] = sum(bool(ln.get("고름")) for ln in lines)
skip = {"품": 0, "단가": 0}
top = block["lines"] if block["kind"] != "품셈" else []
for line, one in zip(top, [*lines, *mats]):
cell = mine.get(line.get("cid")) or {}
skip["단가"] += sum(k in cell for k in PRICE_KEYS)
if "qty" not in cell:
continue
hand, each = (one.get("수량") or {}).get("손"), line.get("qty")
if not isinstance(hand, (int, float)) or not each:
skip["품"] += 1
continue
one["수량"] = {"손": round(hand * cell["qty"] / each, 9)}
kept["수량"] += 1
# 맨 위 밖 덮개 = 품셈 블록 줄 · 부른 블록 안 줄(품) · 단가
by_id = {b["id"]: b for b in blocks.values()}
todo = [] if top else [block["id"]]
todo += [ln.get("key") for ln in top if ln.get("kind") == "daega"]
seen: set[str] = set()
while todo:
sub = by_id.get(todo.pop())
if sub is None or sub["id"] in seen:
continue
seen.add(sub["id"])
for cell in (cover.get(sub["cover"]) or {}).values():
skip["품"] += "qty" in cell
skip["단가"] += sum(k in cell for k in PRICE_KEYS)
todo += [ln.get("key") for ln in sub["lines"] if ln.get("kind") == "daega"]
doc = {
"이름": (name or block.get("name") or "").strip(),
"단위": block.get("unit") or "",
"담은로직": lines,
"자재": mats,
}
return {"문서": doc, "담음": kept, "안담음": skip}
def write(doc: dict[str, Any], overwrite: str | None = None) -> dict[str, Any]:
"""지금 층(`store.at` 안)에 씀 — 같은 이름이면 409 · `overwrite` = 같은 이름 그 키를 덮음."""
same = [
path.stem
for path in sorted((store.root() / KIND).glob("SC-*.json"))
if (read_json(path) or {}).get("이름") == doc["이름"]
]
if overwrite:
if overwrite not in same:
raise store.StoreError(409, f"덮어쓸 「{doc['이름']}」 이 없음 — 다시 누름")
old = store.read(KIND, overwrite)
return store.write(KIND, overwrite, old["판"], {**doc, "키": overwrite})
if same:
raise store.StoreError(409, {"같은이름": same[0], "이름": doc["이름"]})
return refs.create(KIND, doc)
+37
View File
@@ -24,6 +24,9 @@
운반공 줄 = {given, own, dist} 같이 → 거리 값 사본(263-2 · 폴더 `~값 해시`)
POST /api/projects/{id}/estimation/library-inputs {kind, layer, owner, key, lib?}
「운반 단가」 모달(263-2) — 담은 로직 줄 입력 · 값(읽기만 · 권한 = library 길과 같음)
POST /api/projects/{id}/estimation/daega/personal {cover, name?, overwrite?, user?}
「내 자료집에 저장」(703-2) — 펼친 일위대가 → 개인 층 사전조합
(`B09_Estimation_Daega_Personal`) · user = 화면 캐시 덮개 · 같은 이름 = 409 {같은이름} → 덮어쓰기(overwrite = 키) · 새 이름
운반 사본 연결 = 셈 때마다 거리로 사본 다시 고름(`Bill_Library.resolve_hauls` · `fix_keys`).
저장 자리 = 프로젝트 안 `B09_Estimation/v1/`(`B09_Estimation_Store`) ·
@@ -53,6 +56,7 @@ from B08_Quantity.B08_Quantity_Router_Material import material_sheets
from B08_Quantity.B08_Quantity_Router_Milestone import read_data as read_milestone
from B08_Quantity.B08_Quantity_Router_Milestone import site_info
from B09_Estimation import B09_Estimation_Bill_Library as library
from B09_Estimation import B09_Estimation_Daega_Personal as personal
from B09_Estimation import B09_Estimation_Prices as prices
from B09_Estimation import B09_Estimation_Sangeun as sangeun
from B09_Estimation import B09_Estimation_Store as store
@@ -71,6 +75,7 @@ from common_util.common_util_project_settings import reset_section
from common_util.common_util_workflow_state import mark_stage
from M02_MasterTemplete import M02_Layer_Access as access
from M02_MasterTemplete.M02_Item_Snapshot import importer
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import _call, _root
try: # 252 공기 초안(draft 폴더 · 지우면 공기 탭 원천 없이)
from B09_Estimation.draft import B09_Draft_Schedule_Source as schedule_draft
@@ -381,6 +386,38 @@ async def library_inputs(
return {"lines": lines}
class PersonalBody(BaseModel):
cover: str = Field(min_length=1, max_length=200)
name: str | None = Field(None, max_length=200)
overwrite: str | None = Field(None, max_length=20)
user: dict[str, Any] | None = None
@router.post("/{project_id}/estimation/daega/personal")
async def save_personal(
project_id: UUID, body: PersonalBody, session: dict[str, Any] = Depends(verify_session)
) -> dict[str, Any]:
root = await _project_root(project_id)
raw = (
body.user if isinstance(body.user, dict) else await asyncio.to_thread(store.read_user, root)
)
cover = store.clean_user(raw).get("daega_cover") or {}
try:
made = await asyncio.to_thread(personal.build, root, body.cover, cover, body.name)
except LookupError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
if not made["문서"]["이름"]:
raise HTTPException(status_code=400, detail="이름이 빔")
folder = await asyncio.to_thread(_root, session, "personal", None, True)
got = await asyncio.to_thread(_call, folder, personal.write, made["문서"], body.overwrite)
return {
"키": got["이름"],
"이름": made["문서"]["이름"],
"담음": made["담음"],
"안담음": made["안담음"],
}
# 166-4 견적서 파일 길 — 같은 머리 · 같은 권한
router.include_router(quotes_router)
+7 -1
View File
@@ -12,6 +12,7 @@
— 고친 칸만(키가 있으면 그 값 · null = 빈칸 · 없으면 기본)
"daega_cover": {블록 cover: {줄 cid: {qty?, labor?, material?, expense?}}}
(702-2 일위대가 고친 칸 덮개 — 수만 · 셈 = `B09_Estimation_Calc_Labor`)
+ 복사본 `K:…` 의 `@copy` {from · name · spec}(703-2 · 글만)
quotes/<자재 키>/ 견적서 파일(166-4 · 여러 개) — 폴더가 목록(키의 못 쓰는 글자는 %XX) ·
읽을 때 materials[키].files = 이름들 · 사용자 값으로는 저장 안 함
@@ -227,12 +228,17 @@ def clean_bill(raw: Any) -> dict[str, Any]:
def clean_cover(raw: Any) -> dict[str, Any]:
"""702-2 고친 칸 덮개 — 열쇠 글 · 칸 = qty · 노 · 재 · 경 수만 · 빈 줄 · 빈 블록 버림."""
"""702-2 고친 칸 덮개 — 열쇠 글 · 칸 = qty · 노 · 재 · 경 수만 · 빈 줄 · 빈 블록 버림 ·
복사본 표지 `@copy`(703-2) = from · name · spec 글만(from 없으면 버림)."""
out: dict[str, Any] = {}
for block, lines in (raw if isinstance(raw, dict) else {}).items():
kept = {}
for cid, cell in (lines if isinstance(lines, dict) else {}).items():
cell = cell if isinstance(cell, dict) else {}
if cid == "@copy":
if _text(cell.get("from")):
kept[cid] = {k: _text(cell[k]) for k in ("from", "name", "spec") if k in cell}
continue
got = {k: _num(cell[k]) for k in COVER_KEYS if _num(cell.get(k)) is not None}
if got:
kept[str(cid)[:200]] = got
@@ -9,6 +9,7 @@
* [저장] 때 서버가 덮개 입력값으로 정본을 다시 셈 · 노임 새 판 · [재적용] 뒤에도 남음(702-4)
* 고친 칸 = 칸 색(`is-edited`) · 「품셈 값」 칸 = 셈 값 · 우클릭 [품셈 값으로] · 빈칸 입력 = 되돌림
* 일위대가 연결(dg) 없는 줄 = 펼치지 않음 · 고른 줄 = 이 파일이 기억(다시 그려도 그대로)
* 우클릭 덧메뉴(703-2) = 복사해 새 일위대가 · 내 자료집에 저장 · 어디 쓰였나(`…_Bill_Fold_Menu`)
* ========================================================================== */
import { createButton } from "@ui/ui_template_elements";
@@ -16,9 +17,11 @@ import { createCard } from "@ui/ui_template_card";
import { createText } from "@ui/ui_template_text";
import { attachHoverInfo } from "@ui/ui_template_hover_info";
import { cellColumn, createDataTable, type DataRow } from "@ui/ui_template_data_table";
import type { MapContextMenuItem } from "@ui/ui_template_context_menu";
import type { Row } from "./B09_Estimation_Calc_Base";
import { money, parseNumber, qty, text, type TabContext } from "./B09_Estimation_UI_Tabs";
import { attachRowMenu } from "./B09_Estimation_UI_Tabs_Bill_Edit";
import { foldMenu } from "./B09_Estimation_UI_Tabs_Bill_Fold_Menu";
/** 고칠 칸 → 덮개 칸 */
const FIELD: Record<string, string> = {
@@ -91,7 +94,14 @@ function clearCovers(ctx: TabContext, ats: FoldLine[]): void {
const covered = (at: FoldLine | undefined, field: string): boolean =>
!!at && (at.line.covered || []).includes(field);
function foldCard(ctx: TabContext, row: Row, block: Row, close: () => void): HTMLElement {
function foldCard(
ctx: TabContext,
raw: DataRow[],
row: Row,
block: Row,
close: () => void,
go: (key: string) => void,
): HTMLElement {
const blocks = new Map<string, Row>(
(ctx.sheets.daega_blocks || []).map((b: Row) => [String(b.key), b]),
);
@@ -168,9 +178,11 @@ function foldCard(ctx: TabContext, row: Row, block: Row, close: () => void): HTM
);
attachRowMenu(table.root, (i) => {
const at = lines[i];
return at && (at.line.covered || []).length
? [["품셈 값으로", () => clearCovers(ctx, [at])]]
: [];
const undo: MapContextMenuItem[] =
at && (at.line.covered || []).length
? [["품셈 값으로", () => clearCovers(ctx, [at])], "-"]
: [];
return [...undo, ...foldMenu(ctx, raw, row, block, at?.line, go)];
});
const actions = [
...(edited.length
@@ -204,6 +216,13 @@ function foldCard(ctx: TabContext, row: Row, block: Row, close: () => void): HTM
/** 펼침 고리 — 설계내역서 표 root 줄 누름 = 그 줄 펼침 · 돌려준 자리 = 표 아래 */
export function attachBillFold(ctx: TabContext, raw: DataRow[], table: HTMLElement): HTMLElement {
const host = document.createElement("div");
/** 어디 쓰였나 → 그 줄로(보이게 · 펼침) */
const go = (key: string): void => {
const i = raw.findIndex((r) => String(r.key) === key);
table.querySelector(`tr[data-i="${i}"]`)?.scrollIntoView({ block: "center" });
open = key;
draw();
};
const draw = (): void => {
const row = raw.find((r) => r.key === open) as Row | undefined;
const ref: Row | undefined = row?.ref;
@@ -214,10 +233,17 @@ export function attachBillFold(ctx: TabContext, raw: DataRow[], table: HTMLEleme
host.replaceChildren(
...(row && block
? [
foldCard(ctx, row, block, () => {
open = null;
draw();
}),
foldCard(
ctx,
raw,
row,
block,
() => {
open = null;
draw();
},
go,
),
]
: []),
);
@@ -0,0 +1,208 @@
/* =============================================================================
* B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts
* B09 설계내역서 펼친 일위대가 우클릭(PLAN 703-2) — 복사해 새 일위대가 · 내 자료집에 저장 · 어디 쓰였나.
*
* 복사 = 이름 · 규격 묻기 → 덮개 `K:…` 에 `@copy` {from · name · spec} + 원본 덮개째(캐시 · 셈 짝이 새 호표 ·
* 번호 끝) → 「이 줄 단가연결을 새 호표로」 물음 · 복사본 지우기 = 그 덮개 통째(쓰는 줄 있으면 안 지움)
* 내 자료집 = POST …/estimation/daega/personal(화면 캐시 덮개째 · 서버가 개인 층 사전조합) ·
* 같은 이름 409 → 「덮어쓰기 · 새 이름」 · 담음 · 안 담음(품 · 단가 덮개) 수 알림
* 어디 쓰였나 = 누른 줄 노임 · 자재 · 중기(그 밖 = 그 호표)를 쓰는 내역 줄 목록 → 누르면 그 줄로
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { showToast } from "@ui/ui_template_elements";
import { askFields } from "@ui/ui_template_item_picker_ask";
import { createListRow } from "@ui/ui_template_list_row";
import type { MapContextMenuItem } from "@ui/ui_template_context_menu";
import { askConfirm, askText, openModal } from "@ui/ui_template_modal";
import type { DataRow } from "@ui/ui_template_data_table";
import { createText } from "@ui/ui_template_text";
import { currentProjectId } from "../A00_Common/b_project_switch";
import type { Row } from "./B09_Estimation_Calc_Base";
import { COPY } from "./B09_Estimation_Calc_Labor";
import type { TabContext } from "./B09_Estimation_UI_Tabs";
import { editCell } from "./B09_Estimation_UI_Tabs_Bill_Edit";
/** 어디 쓰였나 — 같은 품목으로 찾는 줄 */
const GOODS = ["labor", "material", "machine"];
/** 부른 대가 따라가는 깊이 */
const DEPTH = 6;
const covers = (ctx: TabContext): Row => JSON.parse(JSON.stringify(ctx.user.daega_cover || {}));
const newKey = (): string => `K:${Math.random().toString(16).slice(2, 10).padEnd(8, "0")}`;
/** 복사해 새 일위대가 — 덮개째 · 번호 끝 · 이 줄 연결 바꿀지 물음 */
async function copyBlock(ctx: TabContext, raw: DataRow[], row: Row, block: Row): Promise<void> {
const got = await askFields({
title: "복사해 새 일위대가",
note: `「${block.name}」 을 이 공사 안 새 호표로 — 고친 칸까지 복사`,
fields: [
{ key: "name", label: "이름", value: `${block.name} 복사`, required: true },
{ key: "spec", label: "규격", value: block.spec || "" },
],
okLabel: "만들기",
closeLabel: "닫기",
emptyText: "{label} 이 빔",
});
if (!got) return;
const key = newKey();
const all = covers(ctx);
all[key] = { ...(all[block.cover] || {}), [COPY]: { from: block.cover, ...got } }; // 덮개째
const relink = await askConfirm({
title: "단가연결",
message: `「${row.name || ""}」 줄의 단가연결을 새 호표 「${got.name}」 로 바꿀지`,
okLabel: "바꿈",
cancelLabel: "그대로",
});
const next = { ...ctx, user: { ...ctx.user, daega_cover: all } };
const i = raw.findIndex((r) => r.key === row.key);
if (relink && i >= 0) editCell(next, raw, i, "ref", { tab: "dg", key: `DG:${key}` });
else ctx.setUser(next.user);
showToast(`새 호표 「${got.name}」 — 일위대가목록표 끝 · [저장] 때 서버가 다시 셈`, "success");
}
/** 복사본 지우기 — 그 호표를 잇는 내역 줄이 있으면 막음 */
function removeCopy(ctx: TabContext, raw: DataRow[], block: Row): void {
const used = raw.filter((r) => (r.ref as Row | undefined)?.key === block.id);
if (used.length) {
showToast(`잇는 내역 줄 ${used.length} — 단가연결을 먼저 바꿈`, "error");
return;
}
const all = covers(ctx);
delete all[block.cover];
ctx.setUser({ ...ctx.user, daega_cover: all });
}
async function postPersonal(body: Row): Promise<{ ok: boolean; status: number; got: Row }> {
const response = await fetch(
`${API_BASE_URL}/projects/${currentProjectId()}/estimation/daega/personal`,
{
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
const got = ((await response.json().catch(() => null)) as Row | null) ?? {};
return { ok: response.ok, status: response.status, got };
}
/** 내 자료집에 저장 — 개인 층 사전조합(덮개 · 넣을 값 · 고른 자재) · 같은 이름 물음 */
async function savePersonal(ctx: TabContext, block: Row): Promise<void> {
let name = await askText({
title: "내 자료집에 저장",
message: "개인 층 일위대가 사전조합으로 — 넣을 값 · 고른 자재 · 줄 수량 고친 칸까지",
value: block.name || "",
okLabel: "저장",
});
let overwrite: string | null = null;
while (name !== null && name.trim()) {
const body = { cover: block.cover, name: name.trim(), overwrite, user: ctx.user };
const { ok, status, got } = await postPersonal(body);
const same = got.detail?.같은이름;
if (ok) {
const skip: Row = got.안담음 || {};
const left = (skip.품 || 0) + (skip.단가 || 0);
const tail = left
? ` · 안 담은 고친 칸 ${left}(품 ${skip.품} · 단가 ${skip.단가}) — 품은 고정 줄(PLAN 705) 뒤 담김`
: "";
showToast(`내 자료집에 저장 「${got.이름}」(${got.키})${tail}`, "success", 6000);
return;
}
if (status !== 409 || !same) {
const detail = got.detail;
showToast(`저장 못 함 — ${typeof detail === "string" ? detail : status}`, "error");
return;
}
const over = await askConfirm({
title: "같은 이름",
message: `내 자료집에 「${name.trim()}」(${same}) 이 있음 — 덮어쓰기 · 새 이름`,
okLabel: "덮어쓰기",
cancelLabel: "새 이름",
});
if (over) overwrite = same;
else {
overwrite = null;
name = await askText({ title: "새 이름", message: "내 자료집 이름", value: `${name} 2` });
}
}
}
/** 블록 나무에 그 품목(종류 · 키) · 그 호표(id)가 있나 */
function holds(blocks: Map<string, Row>, bid: string, hit: (ln: Row) => boolean): boolean {
const seen = new Set<string>();
const walk = (id: string, depth: number): boolean => {
const b = blocks.get(id);
if (!b || seen.has(id) || depth > DEPTH) return false;
seen.add(id);
return (b.lines || []).some(
(ln: Row) => hit(ln) || (ln.kind === "daega" && walk(String(ln.key), depth + 1)),
);
};
return walk(bid, 0);
}
/** 어디 쓰였나 — 그 품목 · 호표를 쓰는 내역 줄 → 누르면 그 줄로 */
function whereUsed(
ctx: TabContext,
raw: DataRow[],
block: Row,
line: Row,
go: (key: string) => void,
): void {
const blocks = new Map<string, Row>(
(ctx.sheets.daega_blocks || []).map((b: Row) => [String(b.id), b]),
);
const goods = GOODS.includes(line.kind);
const id = line.kind === "daega" ? String(line.key) : String(block.id);
const label = goods ? `${line.name || line.key}` : `${blocks.get(id)?.name || ""}`;
const hit = goods
? (ln: Row) => ln.kind === line.kind && ln.key === line.key
: (ln: Row) => ln.kind === "daega" && ln.key === id;
const used = raw.filter((r) => {
const ref = r.ref as Row | undefined;
if (ref?.tab !== "dg") return false;
return (!goods && String(ref.key) === id) || holds(blocks, String(ref.key), hit);
});
openModal({
title: `어디 쓰였나 — 「${label}」`,
closeLabel: "닫기",
width: "40rem",
mount: (body, close) => {
body.append(
createText(`쓰는 내역 줄 ${used.length} · 누르면 그 줄로`, "muted"),
...used.map((r) =>
createListRow({
label: String(r.name || ""),
sub: [r.spec, r.qty != null ? `${r.qty} ${r.unit || ""}` : ""]
.filter(Boolean)
.join(" · "),
onClick: () => {
close();
go(String(r.key));
},
}),
),
);
},
});
}
/** 펼친 줄 우클릭 덧메뉴 — 복사 · 내 자료집 · 어디 쓰였나(+ 복사본 지우기) */
export function foldMenu(
ctx: TabContext,
raw: DataRow[],
row: Row,
block: Row,
line: Row | undefined,
go: (key: string) => void,
): MapContextMenuItem[] {
return [
["복사해 새 일위대가", () => void copyBlock(ctx, raw, row, block)],
["내 자료집에 저장", () => void savePersonal(ctx, block)],
["어디 쓰였나", () => whereUsed(ctx, raw, block, line || {}, go)],
...(String(block.cover).startsWith("K:")
? [["이 복사본 지우기", () => removeCopy(ctx, raw, block)] as MapContextMenuItem]
: []),
];
}
+194
View File
@@ -0,0 +1,194 @@
"""703-2 B09 펼친 일위대가 우클릭 — 복사해 새 일위대가 · 내 자료집에 저장.
- 복사본 = 덮개 `K:…` 의 `@copy` {from · name · spec} — 셈 짝(`Calc_Labor` 파이썬 · TS 거울)이
원본 블록을 본떠 끝 번호 새 호표 · 덮개 = 제 것 · 원본 없음 = 짝 잃음 ·
저장 다듬기 · 서고 사본 지킴(prune)
- 내 자료집 = 개인 층 사전조합(임시 storage) — 줄 수량 덮개 = 손 · 품 · 단가 덮개 = 안 담음 수 ·
같은 이름 409 → 덮어쓰기 · 새 이름
"""
import copy
import importlib.util
import json
import sys
from pathlib import Path
from typing import Any
import pytest
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B09_Estimation import B09_Estimation_Bill_Library as library # noqa: E402
from B09_Estimation import B09_Estimation_Daega_Personal as personal # noqa: E402
from B09_Estimation import B09_Estimation_Store as store # noqa: E402
from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor # noqa: E402
from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs # noqa: E402
from config import config_system # noqa: E402
from M02_MasterTemplete import M02_MasterTemplete_Items as items_store # noqa: E402
from M02_MasterTemplete import M02_MasterTemplete_Store as m02 # noqa: E402
from M02_MasterTemplete import M02_Template_Layers as layers # noqa: E402
def _load(name: str):
spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(f"{name}.py"))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
b145 = _load("test_145_2_b09_labor_calc")
b702 = _load("test_702_b09_daega_cover")
#: DG9 찰붙임 복사 — 석공 품 0.1 → 0.2(복사본만) · 원본 DG9 덮개 0.12 는 원본에만
COPY = {
"T:DG9": {"labor:L00005#0": {"qty": 0.12}},
"K:aa": {
"@copy": {"from": "T:DG9", "name": "찰붙임 복사", "spec": "규격 B"},
"labor:L00005#0": {"qty": 0.2},
},
"K:bb": {"@copy": {"from": "K:aa", "name": "복사의 복사"}},
"K:cc": {"@copy": {"from": "T:없음"}, "x#0": {"qty": 1}},
}
def _block(out, bid):
return next(b for b in out["daega_blocks"] if b["id"] == bid)
def test_복사본_새_호표_끝_번호_덮개_제것():
daega = b702._covered_excel()
out = compute_labor(daega, b145.SHEETS, b145.SANGEUN, COPY)
src, mine = _block(out, "DG9"), _block(out, "DG:K:aa")
n = len(daega["blocks"])
assert [mine["no"], _block(out, "DG:K:bb")["no"]] == [n + 1, n + 2] # 번호 새로(끝)
assert (mine["name"], mine["spec"], mine["cover"]) == ("찰붙임 복사", "규격 B", "K:aa")
assert src["lines"][0]["qty"] == 0.12 and mine["lines"][0]["qty"] == 0.2
assert mine["lines"][0]["base"]["qty"] == 0.1 # 품셈 값 = 원본 기본값
assert [ln["amount"] for ln in mine["lines"][1:]] == [ln["amount"] for ln in src["lines"][1:]]
twice = _block(out, "DG:K:bb")
assert twice["spec"] == "규격 B" and twice["lines"][0]["qty"] == 0.1 # 덮개 안 물려받음
assert any(r["key"] == "DG:K:aa" for r in out["daega"]) # 목록표 = 내역 단가연결 자리
assert out["daega_orphans"] == [
{"cover": "K:cc", "cid": "@copy"},
{"cover": "K:cc", "cid": "x#0"},
]
def test_거울_파이썬_TS_복사본():
daega = b702._covered_excel()
py = json.loads(json.dumps(compute_labor(daega, b145.SHEETS, b145.SANGEUN, COPY)))
ts = b145._ts(daega, b145.SHEETS, b145.SANGEUN, COPY)
assert b145._same(py, ts["out"])
def test_저장_다듬기_복사본_표지():
raw = {"daega_cover": {**COPY, "K:dd": {"@copy": {"name": "원본 없음"}}}}
got = store.clean_user(raw)["daega_cover"]
assert got["K:aa"]["@copy"] == {"from": "T:DG9", "name": "찰붙임 복사", "spec": "규격 B"}
assert got["K:bb"] == {"@copy": {"from": "K:aa", "name": "복사의 복사"}}
assert "K:dd" not in got
def test_서고_사본_원본_지킴():
user = {"daega_cover": {"K:aa": {"@copy": {"from": "L:combo@system@-@SS1"}}}}
assert library.used_folders(user) == {"combo@system@-@SS1"}
# ── 내 자료집(개인 층 사전조합 · 임시 storage) ─────────────────────────────
@pytest.fixture()
def world(tmp_path, monkeypatch) -> dict[str, Any]:
(tmp_path / "storage").mkdir()
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage"))
root = b145._project(tmp_path / "proj", [(100, "SS000901")])
b145._snap(root, "pipe@100.0", "SS000901", b145.LINES, b145._values(b145.STONE))
folder = layers.personal_dir(7, 42)
folder.mkdir(parents=True)
items_store.ensure(folder)
got = daega_inputs(root)
block = next(b for b in got["blocks"] if b["id"] == got["owners"]["pipe@100.0"])
return {"root": root, "folder": folder, "block": block}
def test_자료집_문서_덮개_손_안담음_수(world):
block = world["block"]
each = block["lines"][1]["qty"] # GF000119 줄 수량(산근 = 손 그대로 0.5)
cover = {
block["cover"]: {
"sangeun:SG:GF000119#0": {"qty": 0.8, "labor": 1000}, # 줄 수량 = 담음 · 단가 = 안 담음
}
}
made = personal.build(world["root"], block["cover"], cover, "내 돌붙임")
doc = made["문서"]
assert each == 0.5 and doc["이름"] == "내 돌붙임"
hands = [ln["수량"]["손"] for ln in doc["담은로직"]]
assert hands == [2, 0.8, 1] # 덮개 줄만 바뀜
assert doc["담은로직"][0]["입력값"] == b145.STONE # 넣을 값 얹음
assert made["담음"] == {"수량": 1, "넣을값": 1, "고른자재": 0}
assert made["안담음"] == {"품": 0, "단가": 1}
# 복사본 = 원본까지 따라감 · 이름 기본 = 복사본 이름 · 덮개 = 복사본 것
cover2 = {"K:aa": {"@copy": {"from": block["cover"], "name": "복사"}}}
cover2["K:aa"]["sangeun:SG:GF000219#0"] = {"qty": 3}
made2 = personal.build(world["root"], "K:aa", cover2)
assert made2["문서"]["이름"] == "복사"
assert [ln["수량"]["손"] for ln in made2["문서"]["담은로직"]] == [3, 0.5, 1]
with pytest.raises(LookupError):
personal.build(world["root"], "S:없음", {})
def test_자료집_같은_이름_물음(world):
block = world["block"]
doc = personal.build(world["root"], block["cover"], {}, "내 돌붙임")["문서"]
with m02.at(world["folder"]):
first = personal.write(doc)
with pytest.raises(m02.StoreError) as e:
personal.write(doc)
assert e.value.status == 409 and e.value.detail["같은이름"] == first["이름"]
changed = copy.deepcopy(doc)
changed["담은로직"][1]["수량"] = {"손": 9}
again = personal.write(changed, first["이름"]) # 덮어쓰기
assert again["이름"] == first["이름"]
other = personal.write({**doc, "이름": "내 돌붙임 2"}) # 새 이름
assert other["이름"] != first["이름"]
saved = json.loads(
(world["folder"] / "precombo" / f"{first['이름']}.json").read_text(encoding="utf-8")
)
assert saved["이름"] == "내 돌붙임" and saved["담은로직"][1]["수량"] == {"손": 9}
assert saved["담은로직"][0]["입력값"] == b145.STONE
assert len(list((world["folder"] / "precombo").glob("SC-*.json"))) == 2
def test_자료집_길_같은_이름_409_덮어쓰기(world, monkeypatch):
"""서버 길 — 화면 캐시 덮개째 · 개인 층(임시 storage) · 같은 이름 409 {같은이름} → 덮어쓰기."""
from fastapi import FastAPI
from fastapi.testclient import TestClient
from B09_Estimation import B09_Estimation_Router as b09
from common_util.common_util_auth import verify_session
async def root_of(_pid):
return world["root"]
monkeypatch.setattr(b09, "_project_root", root_of)
app = FastAPI()
app.include_router(b09.router)
session = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False}
app.dependency_overrides[verify_session] = lambda: session
client = TestClient(app)
block = world["block"]
url = "/api/projects/8b98ca75-c572-4002-8468-2adae4323414/estimation/daega/personal"
user = {"daega_cover": {block["cover"]: {"sangeun:SG:GF000219#0": {"qty": 4}}}}
body = {"cover": block["cover"], "name": "길 시험", "user": user}
first = client.post(url, json=body)
assert first.status_code == 200, first.text
assert first.json()["담음"]["수량"] == 1
again = client.post(url, json=body)
assert again.status_code == 409 and again.json()["detail"]["같은이름"] == first.json()["키"]
over = client.post(url, json={**body, "overwrite": first.json()["키"]})
assert over.status_code == 200 and over.json()["키"] == first.json()["키"]
saved = world["folder"] / "precombo" / f"{first.json()['키']}.json"
assert json.loads(saved.read_text(encoding="utf-8"))["담은로직"][0]["수량"] == {"손": 4}
assert client.post(url, json={**body, "cover": "S:없음"}).status_code == 404