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]: 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]:
+20 -1
View File
@@ -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,
+20 -2
View File
@@ -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)
+37
View File
@@ -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)
+7 -1
View File
@@ -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]
: []),
];
}
+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