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:
@@ -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]:
|
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 {
|
return {
|
||||||
str(ref["lib"])
|
str(ref["lib"])
|
||||||
for row in ref_rows(user)
|
for row in ref_rows(user)
|
||||||
if isinstance(ref := row.get("ref"), dict) and ref.get("tab") == "dg" and ref.get("lib")
|
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]:
|
def prune(project_root: str | Path, user: dict[str, Any]) -> list[str]:
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
고친 칸 덮개(702-2) = 사용자 값 `daega_cover` {블록 cover: {줄 cid: {qty · 노 · 재 · 경 단가}}} —
|
고친 칸 덮개(702-2) = 사용자 값 `daega_cover` {블록 cover: {줄 cid: {qty · 노 · 재 · 경 단가}}} —
|
||||||
있는 칸만 셈 값 대신 · 줄 `base`(품셈 값 = 셈 수량 · 단가) · `covered`(고친 칸) ·
|
있는 칸만 셈 값 대신 · 줄 `base`(품셈 값 = 셈 수량 · 단가) · `covered`(고친 칸) ·
|
||||||
짝 잃은 덮개(그 블록 · 줄 없음) = `daega_orphans` [{cover, cid}] — 지우지 않고 알림만.
|
짝 잃은 덮개(그 블록 · 줄 없음) = `daega_orphans` [{cover, cid}] — 지우지 않고 알림만.
|
||||||
|
복사본(703-2) = 덮개 `K:…` 의 `@copy` {from · name · spec} — 원본 cover 블록을 본뜬 새 호표
|
||||||
|
(id `DG:K:…` · 번호 = 끝 · 줄 · 기본값 = 원본 · 덮개 = 제 것) · 원본 없음 = 짝 잃음 {cover, cid: @copy}.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -30,6 +32,7 @@ from B09_Estimation.B09_Estimation_Calc_Base import SUPPLY, gap, num, rounddown,
|
|||||||
PARTS = ("labor", "material", "expense")
|
PARTS = ("labor", "material", "expense")
|
||||||
COVER_KEYS = ("qty", *PARTS)
|
COVER_KEYS = ("qty", *PARTS)
|
||||||
TAB = "dg"
|
TAB = "dg"
|
||||||
|
COPY = "@copy"
|
||||||
WHY = {
|
WHY = {
|
||||||
"labor": "노임 단가 없음",
|
"labor": "노임 단가 없음",
|
||||||
"material": "자재 적용 단가 없음",
|
"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(
|
def compute_labor(
|
||||||
daega: dict[str, Any] | None,
|
daega: dict[str, Any] | None,
|
||||||
sheets: dict[str, Any],
|
sheets: dict[str, Any],
|
||||||
@@ -161,6 +179,7 @@ def compute_labor(
|
|||||||
"sangeun": sangeun or {},
|
"sangeun": sangeun or {},
|
||||||
}
|
}
|
||||||
cover = cover or {}
|
cover = cover or {}
|
||||||
|
blocks = with_copies(blocks, cover)
|
||||||
by_id = {b["id"]: b for b in blocks}
|
by_id = {b["id"]: b for b in blocks}
|
||||||
no = {b["id"]: n for n, b in enumerate(blocks, 1)}
|
no = {b["id"]: n for n, b in enumerate(blocks, 1)}
|
||||||
done: dict[str, dict[str, Any]] = {}
|
done: dict[str, dict[str, Any]] = {}
|
||||||
@@ -211,7 +230,7 @@ def compute_labor(
|
|||||||
{"cover": key, "cid": cid}
|
{"cover": key, "cid": cid}
|
||||||
for key, lines in cover.items()
|
for key, lines in cover.items()
|
||||||
for cid in lines
|
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 {
|
return {
|
||||||
"daega_blocks": out_blocks,
|
"daega_blocks": out_blocks,
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
* 빈 줄(166-6) = 블록 합 = 아는 줄 합 + blank n · blank_names — 내역 단가 · 부른 대가로는 안 넘김
|
* 빈 줄(166-6) = 블록 합 = 아는 줄 합 + blank n · blank_names — 내역 단가 · 부른 대가로는 안 넘김
|
||||||
* 고친 칸 덮개(702-2) = 사용자 값 daega_cover {블록 cover: {줄 cid: {qty · labor · material · expense}}} —
|
* 고친 칸 덮개(702-2) = 사용자 값 daega_cover {블록 cover: {줄 cid: {qty · labor · material · expense}}} —
|
||||||
* 있는 칸만 셈 값 대신 · 줄 base(품셈 값) · covered(고친 칸) · 짝 잃은 덮개 = daega_orphans(알림만)
|
* 있는 칸만 셈 값 대신 · 줄 base(품셈 값) · covered(고친 칸) · 짝 잃은 덮개 = daega_orphans(알림만)
|
||||||
|
* 복사본(703-2) = 덮개 K:… 의 @copy {from · name · spec} — 원본 cover 블록을 본뜬 새 호표
|
||||||
|
* (id DG:K:… · 번호 = 끝 · 줄 · 기본값 = 원본 · 덮개 = 제 것) · 원본 없음 = 짝 잃음 {cover, cid: @copy}
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -31,6 +33,7 @@ import {
|
|||||||
const PARTS = ["labor", "material", "expense"] as const;
|
const PARTS = ["labor", "material", "expense"] as const;
|
||||||
const COVER_KEYS = ["qty", ...PARTS] as const;
|
const COVER_KEYS = ["qty", ...PARTS] as const;
|
||||||
const TAB = "dg";
|
const TAB = "dg";
|
||||||
|
export const COPY = "@copy";
|
||||||
const WHY: Record<string, string> = {
|
const WHY: Record<string, string> = {
|
||||||
labor: "노임 단가 없음",
|
labor: "노임 단가 없음",
|
||||||
material: "자재 적용 단가 없음",
|
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 이어 붙임 */
|
/** 대가 탭 둘 — daega_blocks(일위대가표) · daega(목록표) · daega_owners + 바탕 gaps 이어 붙임 */
|
||||||
export function computeLabor(
|
export function computeLabor(
|
||||||
daega: Row | null | undefined,
|
daega: Row | null | undefined,
|
||||||
@@ -153,8 +170,8 @@ export function computeLabor(
|
|||||||
sangeun: Row = {},
|
sangeun: Row = {},
|
||||||
covers: Row = {},
|
covers: Row = {},
|
||||||
): Row {
|
): Row {
|
||||||
const blocks: Row[] = (daega || {}).blocks || [];
|
|
||||||
covers = covers || {};
|
covers = covers || {};
|
||||||
|
const blocks: Row[] = withCopies((daega || {}).blocks || [], covers);
|
||||||
const index = (rows: Row[] | undefined): Row =>
|
const index = (rows: Row[] | undefined): Row =>
|
||||||
Object.fromEntries((rows || []).map((r) => [r.key, r]));
|
Object.fromEntries((rows || []).map((r) => [r.key, r]));
|
||||||
const books: Row = {
|
const books: Row = {
|
||||||
@@ -207,7 +224,8 @@ export function computeLabor(
|
|||||||
const orphans: Row[] = [];
|
const orphans: Row[] = [];
|
||||||
for (const [key, lines] of Object.entries(covers))
|
for (const [key, lines] of Object.entries(covers))
|
||||||
for (const cid of Object.keys(lines || {}))
|
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 {
|
return {
|
||||||
daega_blocks: outBlocks,
|
daega_blocks: outBlocks,
|
||||||
daega: listing,
|
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)
|
||||||
@@ -24,6 +24,9 @@
|
|||||||
운반공 줄 = {given, own, dist} 같이 → 거리 값 사본(263-2 · 폴더 `~값 해시`)
|
운반공 줄 = {given, own, dist} 같이 → 거리 값 사본(263-2 · 폴더 `~값 해시`)
|
||||||
POST /api/projects/{id}/estimation/library-inputs {kind, layer, owner, key, lib?}
|
POST /api/projects/{id}/estimation/library-inputs {kind, layer, owner, key, lib?}
|
||||||
「운반 단가」 모달(263-2) — 담은 로직 줄 입력 · 값(읽기만 · 권한 = library 길과 같음)
|
「운반 단가」 모달(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`).
|
운반 사본 연결 = 셈 때마다 거리로 사본 다시 고름(`Bill_Library.resolve_hauls` · `fix_keys`).
|
||||||
|
|
||||||
저장 자리 = 프로젝트 안 `B09_Estimation/v1/`(`B09_Estimation_Store`) ·
|
저장 자리 = 프로젝트 안 `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 read_data as read_milestone
|
||||||
from B08_Quantity.B08_Quantity_Router_Milestone import site_info
|
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_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_Prices as prices
|
||||||
from B09_Estimation import B09_Estimation_Sangeun as sangeun
|
from B09_Estimation import B09_Estimation_Sangeun as sangeun
|
||||||
from B09_Estimation import B09_Estimation_Store as store
|
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 common_util.common_util_workflow_state import mark_stage
|
||||||
from M02_MasterTemplete import M02_Layer_Access as access
|
from M02_MasterTemplete import M02_Layer_Access as access
|
||||||
from M02_MasterTemplete.M02_Item_Snapshot import importer
|
from M02_MasterTemplete.M02_Item_Snapshot import importer
|
||||||
|
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import _call, _root
|
||||||
|
|
||||||
try: # 252 공기 초안(draft 폴더 · 지우면 공기 탭 원천 없이)
|
try: # 252 공기 초안(draft 폴더 · 지우면 공기 탭 원천 없이)
|
||||||
from B09_Estimation.draft import B09_Draft_Schedule_Source as schedule_draft
|
from B09_Estimation.draft import B09_Draft_Schedule_Source as schedule_draft
|
||||||
@@ -381,6 +386,38 @@ async def library_inputs(
|
|||||||
return {"lines": lines}
|
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 견적서 파일 길 — 같은 머리 · 같은 권한
|
# 166-4 견적서 파일 길 — 같은 머리 · 같은 권한
|
||||||
router.include_router(quotes_router)
|
router.include_router(quotes_router)
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
— 고친 칸만(키가 있으면 그 값 · null = 빈칸 · 없으면 기본)
|
— 고친 칸만(키가 있으면 그 값 · null = 빈칸 · 없으면 기본)
|
||||||
"daega_cover": {블록 cover: {줄 cid: {qty?, labor?, material?, expense?}}}
|
"daega_cover": {블록 cover: {줄 cid: {qty?, labor?, material?, expense?}}}
|
||||||
(702-2 일위대가 고친 칸 덮개 — 수만 · 셈 = `B09_Estimation_Calc_Labor`)
|
(702-2 일위대가 고친 칸 덮개 — 수만 · 셈 = `B09_Estimation_Calc_Labor`)
|
||||||
|
+ 복사본 `K:…` 의 `@copy` {from · name · spec}(703-2 · 글만)
|
||||||
|
|
||||||
quotes/<자재 키>/ 견적서 파일(166-4 · 여러 개) — 폴더가 목록(키의 못 쓰는 글자는 %XX) ·
|
quotes/<자재 키>/ 견적서 파일(166-4 · 여러 개) — 폴더가 목록(키의 못 쓰는 글자는 %XX) ·
|
||||||
읽을 때 materials[키].files = 이름들 · 사용자 값으로는 저장 안 함
|
읽을 때 materials[키].files = 이름들 · 사용자 값으로는 저장 안 함
|
||||||
@@ -227,12 +228,17 @@ def clean_bill(raw: Any) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def clean_cover(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] = {}
|
out: dict[str, Any] = {}
|
||||||
for block, lines in (raw if isinstance(raw, dict) else {}).items():
|
for block, lines in (raw if isinstance(raw, dict) else {}).items():
|
||||||
kept = {}
|
kept = {}
|
||||||
for cid, cell in (lines if isinstance(lines, dict) else {}).items():
|
for cid, cell in (lines if isinstance(lines, dict) else {}).items():
|
||||||
cell = cell if isinstance(cell, dict) else {}
|
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}
|
got = {k: _num(cell[k]) for k in COVER_KEYS if _num(cell.get(k)) is not None}
|
||||||
if got:
|
if got:
|
||||||
kept[str(cid)[:200]] = got
|
kept[str(cid)[:200]] = got
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
* [저장] 때 서버가 덮개 입력값으로 정본을 다시 셈 · 노임 새 판 · [재적용] 뒤에도 남음(702-4)
|
* [저장] 때 서버가 덮개 입력값으로 정본을 다시 셈 · 노임 새 판 · [재적용] 뒤에도 남음(702-4)
|
||||||
* 고친 칸 = 칸 색(`is-edited`) · 「품셈 값」 칸 = 셈 값 · 우클릭 [품셈 값으로] · 빈칸 입력 = 되돌림
|
* 고친 칸 = 칸 색(`is-edited`) · 「품셈 값」 칸 = 셈 값 · 우클릭 [품셈 값으로] · 빈칸 입력 = 되돌림
|
||||||
* 일위대가 연결(dg) 없는 줄 = 펼치지 않음 · 고른 줄 = 이 파일이 기억(다시 그려도 그대로)
|
* 일위대가 연결(dg) 없는 줄 = 펼치지 않음 · 고른 줄 = 이 파일이 기억(다시 그려도 그대로)
|
||||||
|
* 우클릭 덧메뉴(703-2) = 복사해 새 일위대가 · 내 자료집에 저장 · 어디 쓰였나(`…_Bill_Fold_Menu`)
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
import { createButton } from "@ui/ui_template_elements";
|
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 { createText } from "@ui/ui_template_text";
|
||||||
import { attachHoverInfo } from "@ui/ui_template_hover_info";
|
import { attachHoverInfo } from "@ui/ui_template_hover_info";
|
||||||
import { cellColumn, createDataTable, type DataRow } from "@ui/ui_template_data_table";
|
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 type { Row } from "./B09_Estimation_Calc_Base";
|
||||||
import { money, parseNumber, qty, text, type TabContext } from "./B09_Estimation_UI_Tabs";
|
import { money, parseNumber, qty, text, type TabContext } from "./B09_Estimation_UI_Tabs";
|
||||||
import { attachRowMenu } from "./B09_Estimation_UI_Tabs_Bill_Edit";
|
import { attachRowMenu } from "./B09_Estimation_UI_Tabs_Bill_Edit";
|
||||||
|
import { foldMenu } from "./B09_Estimation_UI_Tabs_Bill_Fold_Menu";
|
||||||
|
|
||||||
/** 고칠 칸 → 덮개 칸 */
|
/** 고칠 칸 → 덮개 칸 */
|
||||||
const FIELD: Record<string, string> = {
|
const FIELD: Record<string, string> = {
|
||||||
@@ -91,7 +94,14 @@ function clearCovers(ctx: TabContext, ats: FoldLine[]): void {
|
|||||||
const covered = (at: FoldLine | undefined, field: string): boolean =>
|
const covered = (at: FoldLine | undefined, field: string): boolean =>
|
||||||
!!at && (at.line.covered || []).includes(field);
|
!!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>(
|
const blocks = new Map<string, Row>(
|
||||||
(ctx.sheets.daega_blocks || []).map((b: Row) => [String(b.key), b]),
|
(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) => {
|
attachRowMenu(table.root, (i) => {
|
||||||
const at = lines[i];
|
const at = lines[i];
|
||||||
return at && (at.line.covered || []).length
|
const undo: MapContextMenuItem[] =
|
||||||
? [["품셈 값으로", () => clearCovers(ctx, [at])]]
|
at && (at.line.covered || []).length
|
||||||
: [];
|
? [["품셈 값으로", () => clearCovers(ctx, [at])], "-"]
|
||||||
|
: [];
|
||||||
|
return [...undo, ...foldMenu(ctx, raw, row, block, at?.line, go)];
|
||||||
});
|
});
|
||||||
const actions = [
|
const actions = [
|
||||||
...(edited.length
|
...(edited.length
|
||||||
@@ -204,6 +216,13 @@ function foldCard(ctx: TabContext, row: Row, block: Row, close: () => void): HTM
|
|||||||
/** 펼침 고리 — 설계내역서 표 root 줄 누름 = 그 줄 펼침 · 돌려준 자리 = 표 아래 */
|
/** 펼침 고리 — 설계내역서 표 root 줄 누름 = 그 줄 펼침 · 돌려준 자리 = 표 아래 */
|
||||||
export function attachBillFold(ctx: TabContext, raw: DataRow[], table: HTMLElement): HTMLElement {
|
export function attachBillFold(ctx: TabContext, raw: DataRow[], table: HTMLElement): HTMLElement {
|
||||||
const host = document.createElement("div");
|
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 draw = (): void => {
|
||||||
const row = raw.find((r) => r.key === open) as Row | undefined;
|
const row = raw.find((r) => r.key === open) as Row | undefined;
|
||||||
const ref: Row | undefined = row?.ref;
|
const ref: Row | undefined = row?.ref;
|
||||||
@@ -214,10 +233,17 @@ export function attachBillFold(ctx: TabContext, raw: DataRow[], table: HTMLEleme
|
|||||||
host.replaceChildren(
|
host.replaceChildren(
|
||||||
...(row && block
|
...(row && block
|
||||||
? [
|
? [
|
||||||
foldCard(ctx, row, block, () => {
|
foldCard(
|
||||||
open = null;
|
ctx,
|
||||||
draw();
|
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]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user