Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_8
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]:
|
||||
"""설계내역서 덮개 줄 · 거리이정표 운반 연결(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]:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -198,6 +198,31 @@ def refresh_book(keys: list[str], book: dict[str, Any] | None = None) -> dict[st
|
||||
"rows": rows,
|
||||
"driver_factor": driver_factor(master),
|
||||
"fx": fx_inputs(master),
|
||||
"labor_edition": labor_editions(keys), # 703-3 미리 보기 「노임 판 전 → 후」
|
||||
}
|
||||
|
||||
|
||||
def labor_editions(keys: list[str]) -> dict[str, str]:
|
||||
"""노임 판(703-3) — 수요 노무 줄 구분(건설업 · 산림 …)마다 M01 `인력.json` 그 구분 `판`."""
|
||||
files, master = m01.loaded()
|
||||
groups = (files.get("인력.json") or {}).get("구분") or {}
|
||||
out: dict[str, str] = {}
|
||||
for key in demand(master, keys)["labor"]:
|
||||
kind = str((_row(master, key) or {}).get("구분") or "")
|
||||
if (groups.get(kind) or {}).get("판"):
|
||||
out[kind] = str(groups[kind]["판"])
|
||||
return out
|
||||
|
||||
|
||||
def book_diff(old: dict[str, Any] | None, new: dict[str, Any]) -> dict[str, int | bool]:
|
||||
"""두 책 견줌(저장 꼴 JSON) — 새 줄 · 바뀐 줄 수 · 운전사 계수 · 환율 바뀜(203-1 · 703-3)."""
|
||||
new = json.loads(json.dumps(new, ensure_ascii=False))
|
||||
rows = (old or {}).get("rows") or {}
|
||||
return {
|
||||
"new": sum(key not in rows for key in new["rows"]),
|
||||
"changed": sum(key in rows and rows[key] != row for key, row in new["rows"].items()),
|
||||
"factor": (old or {}).get("driver_factor") != new["driver_factor"],
|
||||
"fx": (old or {}).get("fx") != new["fx"],
|
||||
}
|
||||
|
||||
|
||||
@@ -206,15 +231,12 @@ def book_stale(book: dict[str, Any] | None, keys: list[str]) -> str | None:
|
||||
if book is None:
|
||||
return "단가 아직 안 굳힘"
|
||||
# 저장 꼴(JSON)로 견줌 · M01 원본이 지워진 줄 = 옛 줄 그대로(낡음 까닭에서 빠짐 · 296)
|
||||
fresh = json.loads(json.dumps(refresh_book(keys, book), ensure_ascii=False))
|
||||
rows = book.get("rows") or {}
|
||||
new = sum(key not in rows for key in fresh["rows"])
|
||||
changed = sum(key in rows and rows[key] != row for key, row in fresh["rows"].items())
|
||||
diff = book_diff(book, refresh_book(keys, book))
|
||||
parts = [
|
||||
f"새 단가 줄 {new}" if new else "",
|
||||
f"M01 에서 바뀐 단가 {changed}" if changed else "",
|
||||
"운전사 계수 바뀜" if book.get("driver_factor") != fresh["driver_factor"] else "",
|
||||
"환율 바뀜" if book.get("fx") != fresh["fx"] else "",
|
||||
f"새 단가 줄 {diff['new']}" if diff["new"] else "",
|
||||
f"M01 에서 바뀐 단가 {diff['changed']}" if diff["changed"] else "",
|
||||
"운전사 계수 바뀜" if diff["factor"] else "",
|
||||
"환율 바뀜" if diff["fx"] else "",
|
||||
]
|
||||
return " · ".join(p for p in parts if p) or None
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""B09 [업데이트] 한 곳(PLAN 703-3) — 단가 책 다시 굳힘 + 원본이 바뀐 구조물 사본 [재적용]을 함께.
|
||||
|
||||
미리 보기(`preview`) = 바뀌는 단가 줄(새 줄 + 바뀐 줄 · 지금 수요) · 다시 가져올 구조물 사본
|
||||
(연결된 것 중 원본이 바뀐 것만 · 원본 없음 = 그대로) · 지킬 고친 칸(덮개 칸 수 · 702-4 —
|
||||
재적용 · 새 판 뒤에도 남음) · 노임 판(굳힌 판 → 지금 M01 판 · 옛 책 = 굳힌 날)
|
||||
적용(`reapply_changed` → 부르는 쪽이 수요 다시 → `prices.refresh_book`) — 사본 먼저(수요가 바뀜) ·
|
||||
덮개(사용자 값)는 안 건드림 · 사본 하나가 막혀도 나머지는 함(막힌 것 = 알림).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B09_Estimation import B09_Estimation_Prices as prices
|
||||
from B09_Estimation.B09_Estimation_Calc_Labor import COPY
|
||||
from M02_MasterTemplete import M02_Item_Copy as item_copy
|
||||
from M02_MasterTemplete.M02_Item_Snapshot import linked_snapshots
|
||||
|
||||
|
||||
def changed_copies(root: str | Path) -> list[dict[str, Any]]:
|
||||
"""연결된 구조물 사본 중 원본(층 · 마스터)이 바뀐 것 `[{폴더, 이름}]`."""
|
||||
linked = {one["폴더"] for one in linked_snapshots(root).values()}
|
||||
return [
|
||||
{"폴더": it["폴더"], "이름": it["이름"] or it["폴더"]}
|
||||
for it in item_copy.list_items(root)
|
||||
if it["폴더"] in linked and it["마스터바뀜"]
|
||||
]
|
||||
|
||||
|
||||
def cover_cells(cover: dict[str, Any] | None) -> int:
|
||||
"""고친 칸 수 — 덮개 칸(품 · 수량 · 단가) · 복사본 표지 뺌."""
|
||||
return sum(
|
||||
len(cell) for lines in (cover or {}).values() for cid, cell in lines.items() if cid != COPY
|
||||
)
|
||||
|
||||
|
||||
def edition_text(editions: dict[str, str] | None) -> str:
|
||||
return " · ".join(f"{kind} {day[:7]}" for kind, day in sorted((editions or {}).items()))
|
||||
|
||||
|
||||
def preview(
|
||||
root: str | Path, book: dict[str, Any] | None, keys: list[str], cover: dict | None
|
||||
) -> dict[str, Any]:
|
||||
diff = prices.book_diff(book, prices.refresh_book(keys, book))
|
||||
before = (book or {}).get("labor_edition")
|
||||
made = str((book or {}).get("made") or "")[:10]
|
||||
return {
|
||||
"단가줄": diff["new"] + diff["changed"],
|
||||
"새줄": diff["new"],
|
||||
"바뀐줄": diff["changed"],
|
||||
"계수": diff["factor"],
|
||||
"환율": diff["fx"],
|
||||
"사본": changed_copies(root),
|
||||
"고친칸": cover_cells(cover),
|
||||
"노임판": {
|
||||
"전": edition_text(before) if before else (f"굳힌 날 {made}" if made else "안 굳힘"),
|
||||
"후": edition_text(prices.labor_editions(keys)),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def reapply_changed(root: str | Path, who: dict[str, Any] | None = None) -> dict[str, list]:
|
||||
"""원본이 바뀐 연결 사본 재적용 — `{다시: [이름], 막힘: [이름 — 까닭]}`."""
|
||||
done, stuck = [], []
|
||||
for one in changed_copies(root):
|
||||
try:
|
||||
item_copy.reapply(root, one["폴더"], who)
|
||||
done.append(one["이름"])
|
||||
except (LookupError, ValueError, KeyError, OSError) as error:
|
||||
stuck.append(f"{one['이름']} — {error}")
|
||||
return {"다시": done, "막힘": stuck}
|
||||
@@ -11,8 +11,13 @@
|
||||
원가 · 공기) · 단가 책 · `estimation` 설정 구획 지움 · 서고 사본 정리 · 견적서 파일 남김 →
|
||||
답 = GET 꼴(기준 = 지금 B08 저장본 + 지금 M01 단가) · 단계 = [저장]과 같음
|
||||
POST /api/projects/{id}/estimation/prices {user?}
|
||||
[단가 갱신] — 수요 단가 전부를 지금 M01 값으로 새로 굳힘 · user = 화면 캐시(296 ·
|
||||
지운 줄 · 그 사본은 수요에서 뺌 · 저장은 안 함) · M01 원본이 지워진 줄 = 굳힌 값 그대로
|
||||
[업데이트] — 원본이 바뀐 구조물 사본 재적용(703-3 · `B09_Estimation_Refresh`) →
|
||||
수요 단가 전부를 지금 M01 값으로 새로 굳힘 · user = 화면 캐시(296 · 지운 줄 · 그 사본은
|
||||
수요에서 뺌 · 저장은 안 함) · M01 원본이 지워진 줄 = 굳힌 값 그대로 · 덮개 그대로 ·
|
||||
답 notes 첫 줄 = 바뀐 수
|
||||
POST /api/projects/{id}/estimation/prices/preview {user?}
|
||||
[업데이트] 미리 보기(703-3) — 바뀌는 단가 줄 · 다시 가져올 사본 · 지킬 고친 칸 ·
|
||||
노임 판(쓰지 않음)
|
||||
POST /api/projects/{id}/estimation/logic-preview {code, given, rate, picks}
|
||||
「운반 단가」 모달 계산 절차(253-3) — 그 로직 한 산근을 프로젝트 단가로 풀어 글 줄 · 단가 ·
|
||||
저장 안 함(`render_sangeun_text` 를 `Sangeun.Solver` 로) · `blocks` = 칸 나눔(277-2)
|
||||
@@ -24,6 +29,10 @@
|
||||
운반공 줄 = {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,7 +62,9 @@ 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_Refresh as refresh
|
||||
from B09_Estimation import B09_Estimation_Sangeun as sangeun
|
||||
from B09_Estimation import B09_Estimation_Store as store
|
||||
from B09_Estimation.B09_Estimation_Bill_Items import bill_items, hopyo_prices
|
||||
@@ -71,6 +82,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
|
||||
@@ -264,16 +276,44 @@ async def estimation_reset(project_id: UUID) -> dict[str, Any]:
|
||||
|
||||
@router.post("/{project_id}/estimation/prices")
|
||||
async def refresh_prices(
|
||||
project_id: UUID, body: dict[str, Any] | None = Body(None)
|
||||
project_id: UUID,
|
||||
body: dict[str, Any] | None = Body(None),
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
) -> dict[str, Any]:
|
||||
"""[업데이트] 한 곳(703-3) — 원본이 바뀐 구조물 사본 재적용 → 단가 책 다시 굳힘 ·
|
||||
덮개 그대로."""
|
||||
root = await _project_root(project_id)
|
||||
raw = (body or {}).get("user")
|
||||
draft = store.clean_user(raw) if isinstance(raw, dict) else None # 296 화면 캐시(저장 안 함)
|
||||
again = await asyncio.to_thread(refresh.reapply_changed, root, importer(session))
|
||||
keys = await _demand(root, draft)
|
||||
old = await asyncio.to_thread(store.read_book, root)
|
||||
book = await asyncio.to_thread(prices.refresh_book, keys, old)
|
||||
await asyncio.to_thread(store.write_book, root, book)
|
||||
return await payload(project_id, root, draft)
|
||||
out = await payload(project_id, root, draft)
|
||||
diff = prices.book_diff(old, book)
|
||||
cells = refresh.cover_cells((draft or out.get("user") or {}).get("daega_cover"))
|
||||
out["notes"] = [
|
||||
f"업데이트 — 단가 줄 {diff['new'] + diff['changed']} 바뀜 · 구조물 사본 "
|
||||
f"{len(again['다시'])} 다시 가져옴 · 고친 칸 {cells} 그대로",
|
||||
*(f"재적용 못 함 {why}" for why in again["막힘"]),
|
||||
*(out.get("notes") or []),
|
||||
]
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/{project_id}/estimation/prices/preview")
|
||||
async def refresh_preview(
|
||||
project_id: UUID, body: dict[str, Any] | None = Body(None)
|
||||
) -> dict[str, Any]:
|
||||
"""[업데이트] 미리 보기(703-3) — 바뀌는 단가 줄 · 다시 가져올 사본 · 지킬 고친 칸 · 노임 판."""
|
||||
root = await _project_root(project_id)
|
||||
raw = (body or {}).get("user")
|
||||
draft = store.clean_user(raw) if isinstance(raw, dict) else None
|
||||
keys = await _demand(root, draft)
|
||||
book = await asyncio.to_thread(store.read_book, root)
|
||||
user = draft if draft is not None else await asyncio.to_thread(store.read_user, root)
|
||||
return await asyncio.to_thread(refresh.preview, root, book, keys, user.get("daega_cover"))
|
||||
|
||||
|
||||
class PreviewBody(BaseModel):
|
||||
@@ -385,6 +425,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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,15 +4,23 @@
|
||||
*
|
||||
* [업데이트] = 단가를 지금 M01 값으로 새로 굳힘 · 서버가 낡았다고 보면(`payload.stale` 까닭 한 줄)
|
||||
* 단추에 점 배지(`is-pending`) · 굳힌 시각 · 까닭 = 공용 호버
|
||||
* 미리 보기 고리(703-3) = [업데이트] 누름을 먼저 잡아 POST …/estimation/prices/preview(화면 캐시) →
|
||||
* 「바뀌는 단가 줄 N · 다시 가져올 구조물 사본 K · 고친 칸 M 지킴 · 노임 판 전 → 후」 창 → [적용] =
|
||||
* 원래 단추 일(서버가 사본 재적용 + 단가 다시 굳힘 · 덮개 그대로) · 닫기 = 안 함
|
||||
* 연결 점검 = 「빈 줄 n」(설계내역서 금액 모르는 줄 · 안내 · 집계표와 같은 수 · 701-2) 목록 +
|
||||
* 그 밖 확인 목록(일위대가 · 단가산출 · 재료 등 · 탭 · 키마다 하나)(누르면 그 탭 그 줄로 · 까닭 = 공용 호버) + 서버 알림 · M01 에 없는 키
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { createButton, showToast } from "@ui/ui_template_elements";
|
||||
import { attachHoverInfo } from "@ui/ui_template_hover_info";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
import { dateTimeText } from "../common_util/common_util_time";
|
||||
import { readState } from "../A00_Common/b_page_state";
|
||||
import { currentProjectId } from "../A00_Common/b_project_switch";
|
||||
import { blankRows, tabName, type Link, type TabId } from "./B09_Estimation_UI_Tabs";
|
||||
|
||||
export interface CheckItem {
|
||||
@@ -59,7 +67,65 @@ export function refreshTip(payload: Row | null): string {
|
||||
const made = payload?.book?.made;
|
||||
const when = made ? `단가 굳힘 ${dateTimeText(String(made))}` : "단가 아직 안 굳힘";
|
||||
const stale = payload?.stale;
|
||||
return `${when} · 누르면 지금 M01 값으로 새로 굳힘${stale ? ` · 업데이트 필요 — ${stale}` : ""}`;
|
||||
return `${when} · 누르면 미리 보기 → [적용] = 지금 M01 값으로 새로 굳힘 · 원본 바뀐 구조물 사본 재적용 · 고친 칸 그대로${stale ? ` · 업데이트 필요 — ${stale}` : ""}`;
|
||||
}
|
||||
|
||||
/** [업데이트] 미리 보기 글 줄(703-3) */
|
||||
export function previewLines(got: Row): string[] {
|
||||
const copies: Row[] = got.사본 || [];
|
||||
const edition = got.노임판 || {};
|
||||
return [
|
||||
`바뀌는 단가 줄 ${got.단가줄 ?? 0}(새 줄 ${got.새줄 ?? 0} · 값 바뀜 ${got.바뀐줄 ?? 0})` +
|
||||
`${got.계수 ? " · 운전사 계수 바뀜" : ""}${got.환율 ? " · 환율 바뀜" : ""}`,
|
||||
`다시 가져올 구조물 사본 ${copies.length}${copies.length ? ` — ${copies.map((c) => c.이름).join(" · ")}` : ""}`,
|
||||
`고친 칸 ${got.고친칸 ?? 0} 지킴(일위대가 덮개 · 새 판 · 재적용 뒤에도 그대로)`,
|
||||
`노임 판 ${edition.전 || "—"} → ${edition.후 || "—"}`,
|
||||
];
|
||||
}
|
||||
|
||||
/** [업데이트] 누름 앞 미리 보기 — [적용] 이면 원래 단추 일 · 닫기 = 안 함 */
|
||||
function attachPreview(refresh: HTMLElement, payload: () => Row | null): void {
|
||||
let pass = false;
|
||||
refresh.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
if (pass) return void (pass = false);
|
||||
event.stopImmediatePropagation();
|
||||
const projectId = currentProjectId();
|
||||
const user = readState<Row>("b09-estimation", projectId) ?? payload()?.user ?? {};
|
||||
void fetch(`${API_BASE_URL}/projects/${projectId}/estimation/prices/preview`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user }),
|
||||
})
|
||||
.then(async (response) => {
|
||||
const got = (await response.json()) as Row;
|
||||
if (!response.ok) throw new Error(String(got?.detail ?? response.status));
|
||||
openModal({
|
||||
title: "업데이트 미리 보기",
|
||||
closeLabel: "닫기",
|
||||
width: "40rem",
|
||||
mount: (body, close, head) => {
|
||||
const apply = createButton({
|
||||
label: "적용",
|
||||
variant: "filled",
|
||||
onClick: () => {
|
||||
close();
|
||||
pass = true;
|
||||
refresh.click();
|
||||
},
|
||||
});
|
||||
apply.dataset.b09RefreshApply = "";
|
||||
head.append(apply);
|
||||
body.append(...previewLines(got).map((line) => createText(line)));
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((error) => showToast(`미리 보기 못 함 — ${String(error)}`, "error"));
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
export interface CheckPanel {
|
||||
@@ -72,6 +138,7 @@ export function createCheckPanel(refresh: HTMLElement, goto: (link: Link) => voi
|
||||
let payload: Row | null = null;
|
||||
let items: CheckItem[] = [];
|
||||
attachHoverInfo(refresh, () => refreshTip(payload));
|
||||
attachPreview(refresh, () => payload);
|
||||
const box = document.createElement("div");
|
||||
box.className = "b09-check";
|
||||
attachHoverInfo(box, (row) => items[Number(row.dataset.i)]?.why, "[data-i]");
|
||||
|
||||
@@ -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]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"""703-3 B09 [업데이트] 한 곳 — 단가 책 다시 굳힘 + 원본이 바뀐 구조물 사본 재적용.
|
||||
|
||||
- 미리 보기 수(`Refresh.preview`) = 적용 뒤 책 견줌(`Prices.book_diff`) 수 · 노임 판 전 → 후 · 고친 칸 수
|
||||
- 재적용 = 연결된 사본 중 원본이 바뀐 것만 · 하나가 막혀도 나머지 · 덮개(사용자 값) 안 건드림
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
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_Prices as prices # noqa: E402
|
||||
from B09_Estimation import B09_Estimation_Refresh as refresh # noqa: E402
|
||||
from B09_Estimation import B09_Estimation_Store as store # noqa: E402
|
||||
|
||||
KEYS = ["LB000001", "LB000033"] # 작업반장(건설업) · 석공
|
||||
|
||||
|
||||
def test_책_견줌_미리보기_수_적용_수_같음(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(refresh, "changed_copies", lambda root: [])
|
||||
new = prices.refresh_book(KEYS)
|
||||
assert new["labor_edition"] == prices.labor_editions(KEYS) and new["labor_edition"]
|
||||
old = json.loads(json.dumps(new))
|
||||
old["rows"]["LB000001"] = {**old["rows"]["LB000001"], "price": 1} # 옛 판 값
|
||||
del old["rows"]["LB000033"] # 새 줄
|
||||
old["labor_edition"] = {"건설업": "2025-09-01"}
|
||||
cover = {
|
||||
"S:a": {"x#0": {"qty": 1, "labor": 2}},
|
||||
"K:b": {"@copy": {"from": "S:a"}, "y#0": {"qty": 3}},
|
||||
}
|
||||
got = refresh.preview(tmp_path, old, KEYS, cover)
|
||||
after = prices.refresh_book(KEYS, old)
|
||||
diff = prices.book_diff(old, after)
|
||||
assert (got["새줄"], got["바뀐줄"]) == (diff["new"], diff["changed"]) == (1, 1)
|
||||
assert got["단가줄"] == 2 and got["고친칸"] == 3 and got["사본"] == []
|
||||
assert got["노임판"]["전"] == "건설업 2025-09"
|
||||
assert got["노임판"]["후"].startswith("건설업 2026")
|
||||
old.pop("labor_edition")
|
||||
assert refresh.preview(tmp_path, old, KEYS, None)["노임판"]["전"].startswith("굳힌 날 ")
|
||||
assert prices.book_diff(after, prices.refresh_book(KEYS, after)) == {
|
||||
"new": 0,
|
||||
"changed": 0,
|
||||
"factor": False,
|
||||
"fx": False,
|
||||
}
|
||||
assert prices.book_stale(after, KEYS) is None
|
||||
|
||||
|
||||
def test_재적용_원본_바뀐_사본만_막혀도_나머지_덮개_그대로(tmp_path, monkeypatch):
|
||||
cover = {"S:a": {"x#0": {"qty": 0.3}}}
|
||||
store.write_user(tmp_path, {"daega_cover": cover})
|
||||
monkeypatch.setattr(
|
||||
refresh,
|
||||
"linked_snapshots",
|
||||
lambda root: {"p1": {"폴더": "a"}, "p2": {"폴더": "b"}, "p3": {"폴더": "c"}},
|
||||
)
|
||||
items = [
|
||||
{"폴더": "a", "이름": "가", "마스터바뀜": True},
|
||||
{"폴더": "b", "이름": "나", "마스터바뀜": False}, # 원본 그대로 = 안 함
|
||||
{"폴더": "c", "이름": "다", "마스터바뀜": True},
|
||||
{"폴더": "z", "이름": "끊김", "마스터바뀜": True}, # 연결 안 됨 = 안 함
|
||||
]
|
||||
monkeypatch.setattr(refresh.item_copy, "list_items", lambda root: items)
|
||||
calls = []
|
||||
|
||||
def fake(root, folder, who=None):
|
||||
calls.append(folder)
|
||||
if folder == "c":
|
||||
raise LookupError("원본 층 없음")
|
||||
|
||||
monkeypatch.setattr(refresh.item_copy, "reapply", fake)
|
||||
assert [c["이름"] for c in refresh.changed_copies(tmp_path)] == ["가", "다"]
|
||||
got = refresh.reapply_changed(tmp_path)
|
||||
assert calls == ["a", "c"]
|
||||
assert got == {"다시": ["가"], "막힘": ["다 — 원본 층 없음"]}
|
||||
assert store.read_user(tmp_path)["daega_cover"] == cover
|
||||
@@ -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