Merge remote-tracking branch 'origin/sub_laptop_8' into sub_laptop_5

This commit is contained in:
2026-10-31 13:27:38 +09:00
13 changed files with 668 additions and 25 deletions
+15
View File
@@ -119,6 +119,21 @@ function comboSection(
return section;
}
const source = sourceOf(request.projectId, copy.폴더);
// 702-4 B09 원가에서 고친 일위대가 칸(덮개) — 카드 금액 = 사본 셈(고치기 전) · 셈 답 `B09고침` 수
const covered = note("");
covered.hidden = true;
const labor = source.labor;
if (labor) {
const calc = labor.calc;
labor.calc = (...args) =>
calc(...args).then((got) => {
const n = Number((got as { B09고침?: number }).B09고침) || 0;
covered.textContent = `B09 원가에서 고친 일위대가 칸 ${n} — 이 카드 금액은 고치기 전 사본 값 · 원가 금액은 B09`;
covered.hidden = !n;
return got;
});
}
section.append(covered);
if (!copy.부분.includes("도면")) section.append(note(L("B08_Quantity_Drawing_NoDrawing")));
const load = (): void => void cards.load({ id: copy.폴더, label: name });
// 카드 안 [저장]은 없음(자료 자리에 write 없음) — [수정]만 사본 편집 화면으로(163-3)
+36 -7
View File
@@ -16,6 +16,9 @@
블록 비목 합 = ROUNDDOWN(Σ, 0) · 합계 = 세 비목 합.
빈칸 = 단가 · 수량을 낸 원천이 없으면 None · 「연결 없음」 목록(gaps).
빈 줄(166-6) = 블록 합 = 아는 줄 합 + `blank` n · `blank_names` — 내역 단가 · 부른 대가로는 안 넘김.
고친 칸 덮개(702-2) = 사용자 값 `daega_cover` {블록 cover: {줄 cid: {qty · 노 · 재 · 경 단가}}} —
있는 칸만 셈 값 대신 · 줄 `base`(품셈 값 = 셈 수량 · 단가) · `covered`(고친 칸) ·
짝 잃은 덮개(그 블록 · 줄 없음) = `daega_orphans` [{cover, cid}] — 지우지 않고 알림만.
"""
from __future__ import annotations
@@ -25,6 +28,7 @@ from typing import Any
from B09_Estimation.B09_Estimation_Calc_Base import SUPPLY, gap, num, rounddown, total
PARTS = ("labor", "material", "expense")
COVER_KEYS = ("qty", *PARTS)
TAB = "dg"
WHY = {
"labor": "노임 단가 없음",
@@ -69,24 +73,35 @@ def _base(spec: str, lines: list[dict[str, Any]]) -> float | None:
return total([ln["amounts"]["labor"] for ln in lines if ln["kind"] == "labor"])
def _cover(line: dict[str, Any], own: dict[str, Any], qty: Any, prices: dict) -> tuple:
"""덮개 한 줄 — (수량, 단가, 덧칸) · 덧칸 = `base`(품셈 값) · `covered`(고친 칸 차례)."""
mine = own.get(line.get("cid")) or {}
keys = [k for k in COVER_KEYS if k in mine]
if not keys:
return qty, prices, {}
extra = {"base": {"qty": qty, **prices}, "covered": keys}
qty = num(mine["qty"]) if "qty" in mine else qty
return qty, {p: num(mine[p]) if p in mine else v for p, v in prices.items()}, extra
def _block(
block: dict[str, Any],
books: dict[str, Any],
sums: dict[str, dict],
gaps: list,
own: dict[str, Any],
) -> dict[str, Any]:
lines = []
for line in block["lines"]:
if line["kind"] == "pct":
continue
qty = num(line.get("qty"))
prices = _prices(line, books, sums)
qty, prices, extra = _cover(line, own, num(line.get("qty")), _prices(line, books, sums))
amounts = {
part: None if p is None or qty is None else rounddown(p * qty, 1)
for part, p in prices.items()
}
ref = {"ref": {"key": line["key"]}} if line.get("key") else {} # 집계표(145-5) 이음
lines.append({**line, **ref, "qty": qty, "prices": prices, "amounts": amounts})
lines.append({**line, **ref, "qty": qty, "prices": prices, "amounts": amounts, **extra})
if qty is None or None in prices.values():
why = line.get("why") or (
"수량 없음" if qty is None else WHY.get(line["kind"], "단가 없음")
@@ -96,13 +111,15 @@ def _block(
if line["kind"] != "pct":
continue
base = _base(line.get("base") or "lines:labor", lines)
pct = num(line.get("qty"))
mine = own.get(line.get("cid")) or {}
pct = num(mine["qty"]) if "qty" in mine else num(line.get("qty"))
extra = {"base": {"qty": num(line.get("qty"))}, "covered": ["qty"]} if "qty" in mine else {}
prices = dict.fromkeys(PARTS, 0.0)
amounts = dict.fromkeys(PARTS, 0.0)
part = line.get("part") or "expense"
prices[part] = base
amounts[part] = None if base is None or pct is None else rounddown(base * pct / 100, 1)
lines.append({**line, "qty": pct, "prices": prices, "amounts": amounts})
lines.append({**line, "qty": pct, "prices": prices, "amounts": amounts, **extra})
if amounts[part] is None:
gaps.append(
gap(TAB, block["id"], f"{block['name']} · {line.get('name')}", "% 밑수 없음")
@@ -114,7 +131,7 @@ def _block(
parts = {part: rounddown(sum(ln["amounts"][part] for ln in known), 0) for part in PARTS}
blank_names = [ln.get("name") or "" for ln in lines if ln["amount"] is None]
return {
**{k: block.get(k) for k in ("id", "kind", "name", "spec", "unit", "cite")},
**{k: block.get(k) for k in ("id", "kind", "name", "spec", "unit", "cite", "cover")},
**({"spec_raw": block["spec_raw"]} if block.get("spec_raw") else {}), # 157-4 풍선
"key": block["id"],
"lines": lines,
@@ -129,6 +146,7 @@ def compute_labor(
daega: dict[str, Any] | None,
sheets: dict[str, Any],
sangeun: dict[str, Any] | None = None,
cover: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""대가 탭 둘 — `daega_blocks`(일위대가표) · `daega`(목록표) · `daega_owners`
+ 바탕 gaps 이어 붙임.
@@ -142,6 +160,7 @@ def compute_labor(
"machine": {r["key"]: r for r in sheets.get("machine") or []},
"sangeun": sangeun or {},
}
cover = cover or {}
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]] = {}
@@ -153,7 +172,7 @@ def compute_labor(
for line in by_id[bid]["lines"]:
if line["kind"] == "daega":
run(line.get("key"), (*trail, bid))
done[bid] = _block(by_id[bid], books, done, gaps)
done[bid] = _block(by_id[bid], books, done, gaps, cover.get(by_id[bid].get("cover")) or {})
for b in blocks:
run(b["id"], ())
@@ -185,10 +204,20 @@ def compute_labor(
}
for b in out_blocks
]
cids: dict[str, set] = {}
for b in blocks:
cids.setdefault(b.get("cover"), set()).update(ln.get("cid") for ln in b["lines"])
orphans = [
{"cover": key, "cid": cid}
for key, lines in cover.items()
for cid in lines
if cid not in cids.get(key, ())
]
return {
"daega_blocks": out_blocks,
"daega": listing,
"daega_owners": dict(daega.get("owners") or {}),
"daega_orphans": orphans,
"gaps": [*(sheets.get("gaps") or []), *gaps],
}
+41 -8
View File
@@ -13,6 +13,8 @@
* 관급 · 별도계상 자재 = 대가 안 0원(자재대 · 관급 단락에서 셈)
* 빈칸 = 원천이 없으면 null · 「연결 없음」 목록(gaps)
* 빈 줄(166-6) = 블록 합 = 아는 줄 합 + blank n · blank_names — 내역 단가 · 부른 대가로는 안 넘김
* 고친 칸 덮개(702-2) = 사용자 값 daega_cover {블록 cover: {줄 cid: {qty · labor · material · expense}}} —
* 있는 칸만 셈 값 대신 · 줄 base(품셈 값) · covered(고친 칸) · 짝 잃은 덮개 = daega_orphans(알림만)
* ========================================================================== */
import {
@@ -27,6 +29,7 @@ import {
} from "./B09_Estimation_Calc_Base";
const PARTS = ["labor", "material", "expense"] as const;
const COVER_KEYS = ["qty", ...PARTS] as const;
const TAB = "dg";
const WHY: Record<string, string> = {
labor: "노임 단가 없음",
@@ -70,19 +73,29 @@ function pctBase(spec: string, lines: Row[]): Num {
return total(lines.filter((ln) => ln.kind === "labor").map((ln) => ln.amounts.labor));
}
function block(b: Row, books: Row, sums: Record<string, Row>, gaps: Gap[]): Row {
/** 덮개 한 줄 — [수량, 단가, 덧칸] · 덧칸 = base(품셈 값) · covered(고친 칸 차례) */
function cover(line: Row, own: Row, qty0: Num, prices0: Parts): [Num, Parts, Row] {
const mine: Row = own[line.cid] || {};
const keys = COVER_KEYS.filter((k) => k in mine);
if (!keys.length) return [qty0, prices0, {}];
const extra = { base: { qty: qty0, ...prices0 }, covered: keys };
const prices = { ...prices0 };
for (const p of PARTS) if (p in mine) prices[p] = num(mine[p]);
return ["qty" in mine ? num(mine.qty) : qty0, prices, extra];
}
function block(b: Row, books: Row, sums: Record<string, Row>, gaps: Gap[], own: Row): Row {
const lines: Row[] = [];
for (const line of b.lines) {
if (line.kind === "pct") continue;
const qty = num(line.qty);
const prices = linePrices(line, books, sums);
const [qty, prices, extra] = cover(line, own, num(line.qty), linePrices(line, books, sums));
const amounts = blanks();
for (const part of PARTS) {
const p = prices[part];
amounts[part] = p === null || qty === null ? null : rounddown(p * qty, 1);
}
const ref = line.key ? { ref: { key: line.key } } : {}; // 집계표(145-5) 이음
lines.push({ ...line, ...ref, qty, prices, amounts });
lines.push({ ...line, ...ref, qty, prices, amounts, ...extra });
if (qty === null || PARTS.some((part) => prices[part] === null)) {
const why = line.why || (qty === null ? "수량 없음" : (WHY[line.kind] ?? "단가 없음"));
gaps.push(gap(TAB, b.id, `${b.name} · ${line.name || ""}`, why));
@@ -91,13 +104,15 @@ function block(b: Row, books: Row, sums: Record<string, Row>, gaps: Gap[]): Row
for (const line of b.lines) {
if (line.kind !== "pct") continue;
const base = pctBase(line.base || "lines:labor", lines);
const pct = num(line.qty);
const mine: Row = own[line.cid] || {};
const pct = "qty" in mine ? num(mine.qty) : num(line.qty);
const extra = "qty" in mine ? { base: { qty: num(line.qty) }, covered: ["qty"] } : {};
const prices = zeros();
const amounts = zeros();
const part = (line.part || "expense") as (typeof PARTS)[number];
prices[part] = base;
amounts[part] = base === null || pct === null ? null : rounddown((base * pct) / 100, 1);
lines.push({ ...line, qty: pct, prices, amounts });
lines.push({ ...line, qty: pct, prices, amounts, ...extra });
if (amounts[part] === null)
gaps.push(gap(TAB, b.id, `${b.name} · ${line.name}`, "% 밑수 없음"));
}
@@ -122,6 +137,7 @@ function block(b: Row, books: Row, sums: Record<string, Row>, gaps: Gap[]): Row
...(b.spec_raw ? { spec_raw: b.spec_raw } : {}), // 157-4 풍선(파이썬 짝)
unit: b.unit,
cite: b.cite,
cover: b.cover ?? null,
lines,
...parts,
sum: PARTS.reduce((acc, p) => acc + (parts[p] as number), 0),
@@ -131,8 +147,14 @@ function block(b: Row, books: Row, sums: Record<string, Row>, gaps: Gap[]): Row
}
/** 대가 탭 둘 — daega_blocks(일위대가표) · daega(목록표) · daega_owners + 바탕 gaps 이어 붙임 */
export function computeLabor(daega: Row | null | undefined, sheets: Row, sangeun: Row = {}): Row {
export function computeLabor(
daega: Row | null | undefined,
sheets: Row,
sangeun: Row = {},
covers: Row = {},
): Row {
const blocks: Row[] = (daega || {}).blocks || [];
covers = covers || {};
const index = (rows: Row[] | undefined): Row =>
Object.fromEntries((rows || []).map((r) => [r.key, r]));
const books: Row = {
@@ -148,7 +170,7 @@ export function computeLabor(daega: Row | null | undefined, sheets: Row, sangeun
const run = (bid: string, trail: string[]): void => {
if (bid in done || trail.includes(bid) || !(bid in byId)) return;
for (const line of byId[bid].lines) if (line.kind === "daega") run(line.key, [...trail, bid]);
done[bid] = block(byId[bid], books, done, gaps);
done[bid] = block(byId[bid], books, done, gaps, covers[byId[bid].cover] || {});
};
for (const b of blocks) run(b.id, []);
const outBlocks = blocks.map((b) => {
@@ -176,10 +198,21 @@ export function computeLabor(daega: Row | null | undefined, sheets: Row, sangeun
blank: b.blank,
blank_names: b.blank_names,
}));
const cids = new Map<unknown, Set<string>>();
for (const b of blocks) {
const got = cids.get(b.cover) ?? new Set<string>();
for (const ln of b.lines) got.add(ln.cid);
cids.set(b.cover, got);
}
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 });
return {
daega_blocks: outBlocks,
daega: listing,
daega_owners: { ...((daega || {}).owners || {}) },
daega_orphans: orphans,
gaps: [...(sheets.gaps || []), ...gaps],
};
}
+54 -5
View File
@@ -12,6 +12,9 @@
(155-3 · 사용자 뜻 = 일위대가화 줄 = 대가 · 단가산출 줄 = 산근 · 145 정함 뒤집음)
같은 블록 = 같은 꼴 → 한 호표(id = 내용 해시 · 같은 원본 + 같은 셈 단가 = 하나) ·
덮개 열쇠(702-2 · 내용 해시 밖 · 수량 · 단가가 바뀌어도 그대로) = 블록 `cover` + 줄 `cid` —
cover = 조합 `S:사본 폴더` · 서고 `L:폴더` · 사전조합 `C:참조` · 품셈 `P:로직 + 입력 해시`
cid = 줄 정체(비목 : 노임 · 자재 · 중기 코드 · 산근 · 부른 대가 로직 · 참조) + `#n번째`
줄 수량 = 사본 셈 수량(손 · 면적 알약 칸) — 대가 줄만 ÷ 배(부르는 호표 1 단위 몫 ·
산근 단가는 이미 1 단위).
단가 · 금액 · 끝수는 셈 짝(`B09_Estimation_Calc_Labor`)이 B09 프로젝트 한 벌 단가로 냄.
@@ -78,12 +81,35 @@ class Builder:
self.blocks: dict[str, dict[str, Any]] = {}
self.gaps: list[dict[str, str]] = []
def put(self, block: dict[str, Any]) -> str:
def put(self, block: dict[str, Any], cover: str | None = None) -> str:
bid = f"DG:{_digest([block['kind'], block['origin'], block['lines']])}"
if bid not in self.blocks:
self.blocks[bid] = {"id": bid, **block}
mark = {"품셈": "P", "사전조합": "C"}.get(block["kind"], "S")
if cover is None:
origin = block["origin"]
cover = f"C:{origin['참조']}" if mark == "C" else f"{mark}:{_digest(origin)}"
lines = [{**ln, "cid": cid} for ln, cid in zip(block["lines"], self._cids(block))]
self.blocks[bid] = {"id": bid, "cover": cover, **block, "lines": lines}
return bid
def _cids(self, block: dict[str, Any]) -> list[str]:
"""줄 정체(702-2) — 같은 정체 n번째 = `#n` · 재적용으로 줄이 늘거나 바뀌어도 맞는 줄에."""
seen: dict[str, int] = {}
out = []
for line in block["lines"]:
kind, key = line["kind"], str(line.get("key") or "")
if kind == "daega" and key in self.blocks:
origin = self.blocks[key]["origin"]
key = str(origin.get("로직") or origin.get("참조") or key)
elif kind == "sangeun":
key = key.rsplit(":", 1)[0] # SG:로직:입력 해시 → 로직만
elif kind == "pct":
key = str(line.get("base") or "")
head = f"{kind}:{key or line.get('name') or ''}"
seen[head] = seen.get(head, -1) + 1
out.append(f"{head}#{seen[head]}")
return out
# ── 품셈(로직 하나) ─────────────────────────────────────────────────
def poomsem(self, whole: Any, one: dict[str, Any], title: str = "") -> str | None:
code = str(one.get("로직") or "")
@@ -245,7 +271,7 @@ class Builder:
)
# ── 조합(구조물 사본) ───────────────────────────────────────────────
def combo(self, folder: Path) -> str | None:
def combo(self, folder: Path, cover: str | None = None) -> str | None:
labor = read_json(folder / LABOR_FILE)
if not isinstance(labor, dict):
return None
@@ -280,13 +306,14 @@ class Builder:
"cite": "",
"origin": {k: origin.get(k) for k in ("layer", "owner", "key")},
"lines": lines,
}
},
cover or f"S:{folder.name}",
)
def library(self, folder: Path, manifest: dict[str, Any]) -> str | None:
"""설계내역서 서고 사본(204-2) — 구조물조합 = 조합 블록 · 사전조합 = 겹 대가 블록 그대로."""
if manifest.get("종류") != "precombo":
return self.combo(folder)
return self.combo(folder, f"L:{folder.name}")
labor = read_json(folder / LABOR_FILE)
ref = (manifest.get("원본") or {}).get("참조")
copies = (labor or {}).get("참조사본") or {}
@@ -340,6 +367,28 @@ def _plain(value: Any) -> Any:
return value
def cover_count(project_root: str | Path, folder: str) -> int:
"""702-4 B08 구조물도 카드 — 그 사본 블록 + 부른 블록에 붙은 B09 고친 칸 수(카드 = 사본 셈)."""
from B09_Estimation.B09_Estimation_Store import read_user
cover = read_user(project_root).get("daega_cover") or {}
if not cover:
return 0
got = daega_inputs(
project_root
) # ponytail: 프로젝트 한 벌 셈 — 카드 열 때만 · 느리면 사본 하나로
by_id = {b["id"]: b for b in got["blocks"]}
owners = {linked["폴더"]: o for o, linked in linked_snapshots(project_root).items()}
todo, seen = [got["owners"].get(owners.get(folder, ""))], set()
while todo:
block = by_id.get(todo.pop())
if block is None or block["id"] in seen:
continue
seen.add(block["id"])
todo += [ln.get("key") for ln in block["lines"] if ln["kind"] == "daega"]
return sum(len(cover.get(by_id[bid]["cover"]) or {}) for bid in seen)
def daega_inputs(project_root: str | Path) -> dict[str, Any]:
"""대가 셈 입력 — `{blocks: [...], owners: {구조물 id: 블록 id}}` · 사본 없으면 빈 것 ·
설계내역서 서고 사본(204-2)이 있으면 `libs` {폴더: 블록 id}.
+7 -1
View File
@@ -106,7 +106,9 @@ def compute_sheets(root: Path, inputs: dict[str, Any], user: dict[str, Any]) ->
base = compute_base(inputs, user)
sg = sangeun.build(root, base, user) # 145-1 — 굳힌 단가로 풀이(+ 166-2 손 줄 로직)
by_key = {row["key"]: row for row in sg["sg_list"]}
labor = compute_labor(inputs.get("daega"), {**base, "gaps": []}, by_key)
labor = compute_labor(
inputs.get("daega"), {**base, "gaps": []}, by_key, user.get("daega_cover")
)
books = {"daega": labor["daega"], "sg_list": sg["sg_list"], "sg_hand": sg["sg_hand"]}
sheets = compute_estimation(inputs, user, lambda owner: daega_price(labor, owner), books)
sheets.update(sg)
@@ -187,6 +189,10 @@ async def payload(
inputs["daega"] = await asyncio.to_thread(daega_inputs, root)
library.fix_keys(calc, inputs["daega"].get("libs") or {}) # 263-2 바뀐 사본 = 그 블록
sheets, summary = await asyncio.to_thread(compute_twice, root, inputs, calc)
if lost := len(sheets.get("daega_orphans") or []): # 702-2 데이터 점검 알림
notes.append(
f"짝 잃은 고친 칸 {lost} — 일위대가 줄이 바뀌어 붙을 줄 없음 · 고친 값은 남겨 둠"
)
if schedule_draft:
schedule_draft.attach(inputs, sheets, user)
return {
+20
View File
@@ -10,6 +10,8 @@
(184 설계내역서 덮개 — 꼴은 `B09_Estimation_Calc_Bill` 머리 ·
옛 꼴 {"qty", "rows"}(166-2 · 182)도 그대로 다듬음)
— 고친 칸만(키가 있으면 그 값 · null = 빈칸 · 없으면 기본)
"daega_cover": {블록 cover: {줄 cid: {qty?, labor?, material?, expense?}}}
(702-2 일위대가 고친 칸 덮개 — 수만 · 셈 = `B09_Estimation_Calc_Labor`)
quotes/<자재 키>/ 견적서 파일(166-4 · 여러 개) — 폴더가 목록(키의 못 쓰는 글자는 %XX) ·
읽을 때 materials[키].files = 이름들 · 사용자 값으로는 저장 안 함
@@ -29,6 +31,7 @@ from urllib.parse import unquote
from B09_Estimation.B09_Estimation_Calc_Base import SOURCES, SUPPLY
from B09_Estimation.B09_Estimation_Calc_Bill import SECTIONS
from B09_Estimation.B09_Estimation_Calc_Labor import COVER_KEYS
try: # 252 공기 초안(draft 폴더 · 지우면 공기 값 없이)
from B09_Estimation.draft.B09_Draft_Schedule_Source import clean_schedule
@@ -223,6 +226,21 @@ def clean_bill(raw: Any) -> dict[str, Any]:
return out
def clean_cover(raw: Any) -> dict[str, Any]:
"""702-2 고친 칸 덮개 — 열쇠 글 · 칸 = qty · 노 · 재 · 경 수만 · 빈 줄 · 빈 블록 버림."""
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 {}
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
if kept:
out[str(block)[:200]] = kept
return out
def clean_user(raw: Any) -> dict[str, Any]:
"""사용자 값 다듬기 — 모르는 칸 버림 · 수 칸은 수 · null(빈칸)만."""
raw = raw if isinstance(raw, dict) else {}
@@ -255,6 +273,8 @@ def clean_user(raw: Any) -> dict[str, Any]:
estimate["rates"] = {str(k): _num(v) for k, v in rates.items()}
bill = clean_bill(raw.get("bill"))
out = {"materials": materials, "estimate": estimate, **({"bill": bill} if bill else {})}
if cover := clean_cover(raw.get("daega_cover")):
out["daega_cover"] = cover
if clean_schedule and (schedule := clean_schedule(raw.get("schedule"))):
out["schedule"] = schedule
return out
+3 -1
View File
@@ -101,6 +101,7 @@ export function computeSheets(payload: Row, user: Row): Row {
payload.inputs.daega,
{ ...computeBase(payload.inputs, user), gaps: [] },
sg,
user.daega_cover, // 702-2 고친 칸 덮개
);
const books = { daega: labor.daega, sg_list: kept.sg_list, sg_hand: kept.sg_hand };
const base = computeEstimation(
@@ -265,9 +266,10 @@ export function renderB09Estimation(root: HTMLElement): void {
// 203 안내 글 영역 하나 — 설계내역서 레벨 안내 + 탭 안내 이어 붙임
if (level && hint) hint.textContent = `${level.guide.textContent} · ${hint.textContent}`;
const foot = hint ?? level?.guide;
const fold = bill?.fold?.(table.root); // 702-1 줄 펼침 = 표 아래
// 701-3 띠(원가계산서 「빈 줄 n · 미완성」) = 표 위
const banner = spec.banner ? [createBanner({ tone: "warning", text: spec.banner })] : [];
panel.replaceChildren(...banner, table.root, ...(foot ? [foot] : []));
panel.replaceChildren(...banner, table.root, ...(fold ? [fold] : []), ...(foot ? [foot] : []));
if (at >= 0) requestAnimationFrame(() => table.focusRow(at, false)); // 스크롤만(표시 = 빨강)
};
@@ -24,6 +24,7 @@ import {
openLinkMenu,
type BillTable,
} from "./B09_Estimation_UI_Tabs_Bill_Edit";
import { attachBillFold } from "./B09_Estimation_UI_Tabs_Bill_Fold";
const TEXT_KEYS = ["name", "spec", "unit"];
/** 폴더 줄 수식 칸 — 자손 합(셈) */
@@ -126,6 +127,7 @@ export const buildBill: TabBuild = (ctx): BillTable => {
},
menu: (i) => billMenu(ctx, raw, i),
drop: (i, parent, before) => dropMove(ctx, raw, i, parent, before),
fold: (table) => attachBillFold(ctx, raw, table), // 702-1 줄 펼침 · 고친 칸 덮개
onCell: (i, key) => {
if (key === "connect") openLinkMenu(ctx, raw, i);
},
@@ -32,6 +32,8 @@ export type BillTable = TabTable & {
menu?: (index: number) => MapContextMenuItem[];
/** 끌어 놓기(204) — 줄 index 를 parent(null = 맨 위) 아래 before 앞(null = 끝)으로 · 못 하면 null */
drop?: (index: number, parent: string | null, before: string | null) => (() => void) | null;
/** 줄 펼침(702-1) — 표 root 를 받아 표 아래 자리를 돌려줌 */
fold?: (table: HTMLElement) => HTMLElement;
};
const NEW_NAME = "새 줄";
@@ -0,0 +1,236 @@
/* =============================================================================
* B09_Estimation_UI_Tabs_Bill_Fold.ts
* B09 설계내역서 줄 펼침(PLAN 702-1 · 702-2) — 내역 줄 누름 = 표 아래에 그 줄의 일위대가 구성.
*
* 값 = 일위대가표와 같은 셈(`sheets.daega_blocks` · `B09_Estimation_Calc_Labor`) — 펼친 값 = 일위대가표 값
* 줄 = 호표 줄(품명 · 규격 · 품 · 수량 · 단위 · 노 · 재 · 경 단가 · 금액 · 품셈 값) · 부른 대가 = 그 아래 「ㄴ」(3단까지)
* 고친 칸 덮개(702-2) = 품 · 수량 · 단가 칸 두 번 눌러 고침 → 사용자 값 `daega_cover`
* {블록 cover: {줄 cid: {qty · labor · material · expense}}} · 캐시에 바로 · 셈 짝이 바로 다시 셈 ·
* [저장] 때 서버가 덮개 입력값으로 정본을 다시 셈 · 노임 새 판 · [재적용] 뒤에도 남음(702-4)
* 고친 칸 = 칸 색(`is-edited`) · 「품셈 값」 칸 = 셈 값 · 우클릭 [품셈 값으로] · 빈칸 입력 = 되돌림
* 일위대가 연결(dg) 없는 줄 = 펼치지 않음 · 고른 줄 = 이 파일이 기억(다시 그려도 그대로)
* ========================================================================== */
import { createButton } from "@ui/ui_template_elements";
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 { 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";
/** 고칠 칸 → 덮개 칸 */
const FIELD: Record<string, string> = {
qty: "qty",
labor_price: "labor",
material_price: "material",
expense_price: "expense",
};
const LABEL: Record<string, string> = { qty: "품", labor: "노", material: "재", expense: "경" };
/** 부른 대가 펼침 깊이 */
const DEPTH = 3;
/** 펼친 줄 — 덮개 자리(블록 cover · 줄 cid) + 셈 줄 */
interface FoldLine {
cover: string | null;
line: Row;
level: number;
}
/** 지금 펼친 내역 줄 키(다시 그려도 그대로) */
let open: string | null = null;
const plain = (v: unknown): string =>
typeof v === "number" ? v.toLocaleString("ko-KR", { maximumFractionDigits: 4 }) : "빈칸";
/** 품셈 값 칸 글 — 안 고침 = 「같음」 · 고친 칸 = 셈 값 */
function baseText(line: Row): string {
const covered: string[] = line.covered || [];
if (!covered.length) return "같음";
return covered.map((k) => `${LABEL[k]} ${plain(line.base?.[k])}`).join(" · ");
}
function linesOf(block: Row, blocks: Map<string, Row>, level: number, trail: string[]): FoldLine[] {
const out: FoldLine[] = [];
for (const line of block.lines || []) {
out.push({ cover: block.cover ?? null, line, level });
const sub = line.kind === "daega" ? blocks.get(line.key) : undefined;
if (sub && level < DEPTH && !trail.includes(sub.id))
out.push(...linesOf(sub, blocks, level + 1, [...trail, sub.id]));
}
return out;
}
/** 덮개 한 칸 고침 · value null = 그 칸 품셈 값으로 */
function setCover(ctx: TabContext, at: FoldLine, field: string, value: number | null): void {
if (!at.cover) return;
const all: Row = JSON.parse(JSON.stringify(ctx.user.daega_cover || {}));
const lines: Row = all[at.cover] || {};
const cell: Row = { ...(lines[at.line.cid] || {}) };
if (value === null) delete cell[field];
else cell[field] = value;
if (Object.keys(cell).length) lines[at.line.cid] = cell;
else delete lines[at.line.cid];
if (Object.keys(lines).length) all[at.cover] = lines;
else delete all[at.cover];
ctx.setUser({ ...ctx.user, daega_cover: all });
}
/** 줄 하나 덮개 모두 지움 · 여럿이면 한 번에(다시 그림 한 번) */
function clearCovers(ctx: TabContext, ats: FoldLine[]): void {
const all: Row = JSON.parse(JSON.stringify(ctx.user.daega_cover || {}));
for (const at of ats) {
if (!at.cover || !all[at.cover]) continue;
delete all[at.cover][at.line.cid];
if (!Object.keys(all[at.cover]).length) delete all[at.cover];
}
ctx.setUser({ ...ctx.user, daega_cover: all });
}
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 {
const blocks = new Map<string, Row>(
(ctx.sheets.daega_blocks || []).map((b: Row) => [String(b.key), b]),
);
const lines = linesOf(block, blocks, 1, [block.id]);
const rows: DataRow[] = lines.map(({ line, level }) => ({
key: line.cid,
name: `${level > 1 ? "ㄴ " : ""}${line.name || ""}`,
spec: line.spec || "",
qty: line.qty,
unit: line.unit || "",
labor_price: line.prices?.labor,
material_price: line.prices?.material,
expense_price: line.prices?.expense,
amount: line.amount,
base: baseText(line),
note: line.ref_no || line.why || "",
}));
const edited = lines.filter((at) => (at.line.covered || []).length);
const columns = [
text("name", "품명", 200),
text("spec", "규격", 140),
qty("qty", "품 · 수량"),
text("unit", "단위", 48),
money("labor_price", "노무비", ["단가"], 1),
money("material_price", "재료비", ["단가"], 1),
money("expense_price", "경비", ["단가"], 1),
money("amount", "금액", undefined, 1),
text("base", "품셈 값", 150),
text("note", "비고", 120),
];
const table = createDataTable({
columns,
rows,
total: { name: "합 계", amount: block.sum, note: block.blank ? `빈 줄 ${block.blank}` : "" },
frozen: 1,
empty: "",
editOnDouble: true,
onCellClick: () => undefined, // 줄 번호 표식(data-i) — 두 번 눌러 고침 · 우클릭 · 풍선 자리
editable: (i, key) => {
const at = lines[i];
if (!at?.cover || !(key in FIELD)) return false;
return at.line.kind !== "pct" || key === "qty"; // % 줄 = 율만
},
onEdit: (i, key, value) => {
const at = lines[i];
const field = FIELD[key];
const v = parseNumber(value);
if (!at || !field || v === undefined) return;
const base = covered(at, field)
? at.line.base?.[field]
: field === "qty"
? at.line.qty
: at.line.prices?.[field];
if (v === null || v === base) {
if (covered(at, field)) setCover(ctx, at, field, null); // 빈칸 · 셈 값 = 품셈 값으로
return;
}
setCover(ctx, at, field, v);
},
cellClass: (i, key) => (covered(lines[i], FIELD[key]) ? "is-edited" : null),
level: { key: "name", of: (i) => lines[i]?.level },
});
table.root.dataset.b09Fold = "";
attachHoverInfo(
table.root,
(cell) => {
const key = columns[cellColumn(cell as HTMLTableCellElement)]?.key ?? "";
const at = lines[Number(cell.parentElement?.dataset.i)];
const field = FIELD[key];
if (!at || !field || !covered(at, field)) return null;
return `고친 칸 — 품셈 값 ${plain(at.line.base?.[field])} · 우클릭 [품셈 값으로]`;
},
"tr[data-i] td",
);
attachRowMenu(table.root, (i) => {
const at = lines[i];
return at && (at.line.covered || []).length
? [["품셈 값으로", () => clearCovers(ctx, [at])]]
: [];
});
const actions = [
...(edited.length
? [
createButton({
label: `고친 칸 ${edited.length}줄 모두 품셈 값으로`,
variant: "ghost",
size: "sm",
onClick: () => clearCovers(ctx, edited),
}),
]
: []),
createButton({ label: "접기", variant: "ghost", size: "sm", onClick: close }),
];
const card = createCard({
title: `${row.name || ""} — 일위대가 구성 (대가${block.no} ${block.name || ""})`,
actions,
pad: 12,
body: [
table.root,
createText(
"품 · 수량 · 단가 칸을 두 번 눌러 고침 · 색 칸 = 고친 칸(이 공사만 · 새 단가 판 · [재적용] 뒤에도 남음) · 우클릭 [품셈 값으로] · [저장] 때 서버가 다시 셈",
"muted",
),
],
});
card.classList.add("b09-fold");
return card;
}
/** 펼침 고리 — 설계내역서 표 root 줄 누름 = 그 줄 펼침 · 돌려준 자리 = 표 아래 */
export function attachBillFold(ctx: TabContext, raw: DataRow[], table: HTMLElement): HTMLElement {
const host = document.createElement("div");
const draw = (): void => {
const row = raw.find((r) => r.key === open) as Row | undefined;
const ref: Row | undefined = row?.ref;
const block =
ref?.tab === "dg"
? (ctx.sheets.daega_blocks || []).find((b: Row) => String(b.key) === String(ref.key))
: undefined;
host.replaceChildren(
...(row && block
? [
foldCard(ctx, row, block, () => {
open = null;
draw();
}),
]
: []),
);
};
table.addEventListener("click", (event) => {
const target = event.target as Element;
if (target.closest("input, textarea")) return;
const tr = target.closest<HTMLElement>("tr[data-i]");
if (!tr || !table.contains(tr)) return;
const row = raw[Number(tr.dataset.i)] as Row | undefined;
open = row?.ref?.tab === "dg" ? String(row.key) : null;
draw();
});
draw();
return host;
}
@@ -219,4 +219,12 @@ async def save_item_as(
async def calc_item(
project_id: str, folder: str, session: dict[str, Any] = Depends(verify_session)
) -> dict[str, Any]:
return await _run(copies.calc, await _root(session, project_id), folder)
root = await _root(session, project_id)
out = await _run(copies.calc, root, folder)
from B09_Estimation.B09_Estimation_Labor_Source import cover_count # B09 → M02 순환 피함
try: # 702-4 B09 원가에서 고친 일위대가 칸 수(카드 금액 = 사본 셈 · 고치기 전)
out["B09고침"] = await asyncio.to_thread(cover_count, root, folder)
except Exception: # noqa: BLE001 — 표시만 · 카드 셈은 그대로
out["B09고침"] = 0
return out
@@ -165,7 +165,7 @@ def test_빈칸_규칙():
assert {"노임 단가 없음", "산근 단가 없음(145-1)", "수량 없음", "% 밑수 없음"} <= whys
def _ts(daega, sheets, sangeun):
def _ts(daega, sheets, sangeun, cover=None):
base = IMPORTS.sub("", (ROOT / "B09_Estimation/B09_Estimation_Calc_Base.ts").read_text("utf-8"))
calc = IMPORTS.sub(
"", (ROOT / "B09_Estimation/B09_Estimation_Calc_Labor.ts").read_text("utf-8")
@@ -174,7 +174,8 @@ def _ts(daega, sheets, sangeun):
f"\nconst d = {json.dumps(daega, ensure_ascii=False)};"
f"\nconst s = {json.dumps(sheets, ensure_ascii=False)};"
f"\nconst g = {json.dumps(sangeun, ensure_ascii=False)};"
"\nconst out = computeLabor(d, s, g);"
f"\nconst c = {json.dumps(cover or {}, ensure_ascii=False)};"
"\nconst out = computeLabor(d, s, g, c);"
"\nconsole.log(JSON.stringify({out, price: daegaPrice(out, 'pipe@100.0')}));"
)
result = subprocess.run(
@@ -0,0 +1,240 @@
"""702-2 · 702-4 B09 일위대가 고친 칸 덮개 — 사용자 값 `daega_cover`
{블록 cover: {줄 cid: {qty · labor · material · expense}}}.
- 덮개 열쇠 = 블록 `cover` · 줄 `cid`(`B09_Estimation_Labor_Source` · 내용 해시 밖)
- 셈 = `B09_Estimation_Calc_Labor` 파이썬 · TS 짝(거울) — 고친 칸만 셈 값 대신 · `base` = 품셈 값
- 노임 새 판(바탕 단가 바뀜) · 사본 다시 만듦([재적용]) 뒤에도 덮개 칸 그대로 · 안 고친 칸만 새 값
- 짝 잃은 덮개 = `daega_orphans`(지우지 않음) · 저장 다듬기 = `B09_Estimation_Store.clean_cover`
견본 = `test_145_2_b09_labor_calc` 엑셀 블록 · 견본 사본.
"""
import copy
import importlib.util
import json
import sys
from pathlib import Path
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_Store as store # noqa: E402
from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor, daega_price # noqa: E402
from B09_Estimation.B09_Estimation_Labor_Source import cover_count, daega_inputs # noqa: E402
from M02_MasterTemplete import M02_Item_Snapshot as snapshots # noqa: E402
_spec = importlib.util.spec_from_file_location(
"b145", Path(__file__).with_name("test_145_2_b09_labor_calc.py")
)
b145 = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(b145)
def _covered_excel():
"""엑셀 블록 + cover · cid(사본 길이 붙이는 꼴)."""
daega = copy.deepcopy(b145.EXCEL)
for b in daega["blocks"]:
b["cover"] = f"T:{b['id']}"
for n, ln in enumerate(b["lines"]):
ln["cid"] = f"{ln['kind']}:{ln.get('key') or ln['name']}#{n}"
return daega
#: DG9 찰붙임 석공 품 0.1 → 0.12 · DG2 % 2 → 3 · DG14 산근 SG10 노무 단가 손 · 짝 잃은 둘
COVER = {
"T:DG9": {"labor:L00005#0": {"qty": 0.12}},
"T:DG2": {"pct:공구손료#3": {"qty": 3}},
"T:DG14": {"sangeun:SG10#2": {"labor": 20000}},
"T:없는블록": {"labor:L00017#0": {"qty": 1}},
"T:DG1": {"labor:없는줄#9": {"qty": 1}},
}
def _line(out, bid, n):
return next(b for b in out["daega_blocks"] if b["id"] == bid)["lines"][n]
def test_고친_칸만_셈값_대신():
daega = _covered_excel()
plain = compute_labor(daega, b145.SHEETS, b145.SANGEUN)
out = compute_labor(daega, b145.SHEETS, b145.SANGEUN, COVER)
stone = _line(out, "DG9", 0)
assert stone["qty"] == 0.12 and stone["covered"] == ["qty"]
assert stone["base"] == {"qty": 0.1, "labor": 258935, "material": 0, "expense": 0}
assert stone["amounts"]["labor"] == 31072.2 # ROUNDDOWN(258,935 × 0.12, 1)
assert "covered" not in _line(out, "DG9", 1) # 안 고친 줄 = 덧칸 없음
# 부른 블록이 바뀌면 부르는 블록 금액도(DG14 ← DG9) · 내역 단가도
nine = next(b for b in out["daega"] if b["key"] == "DG9")
assert nine["labor"] == 42556 + 31072 - 25893 # 석공 줄만 바뀜(ROUNDDOWN 0)
assert daega_price(out, "pipe@100.0")["sum"] != daega_price(plain, "pipe@100.0")["sum"]
pct = _line(out, "DG2", 4) # % 줄 = 끝에 셈(차례 4)
assert pct["qty"] == 3 and pct["base"] == {"qty": 2} and pct["covered"] == ["qty"]
sg = _line(out, "DG14", 2)
assert sg["prices"]["labor"] == 20000 and sg["base"]["labor"] == 18084
assert sg["covered"] == ["labor"] and sg["prices"]["material"] == 2498
assert out["daega_orphans"] == [
{"cover": "T:없는블록", "cid": "labor:L00017#0"},
{"cover": "T:DG1", "cid": "labor:없는줄#9"},
]
assert plain["daega_orphans"] == []
def test_덮개가_빈칸을_채움():
daega = _covered_excel()
sheets = {**b145.SHEETS, "labor": b145.SHEETS["labor"][:2]} # 석공 노임 없음
bare = compute_labor(daega, sheets, b145.SANGEUN)
assert _line(bare, "DG9", 0)["amount"] is None
out = compute_labor(daega, sheets, b145.SANGEUN, {"T:DG9": {"labor:L00005#0": {"labor": 1e5}}})
assert _line(out, "DG9", 0)["amount"] == 10000.0
assert not [g for g in out["gaps"] if "찰붙임" in g["name"]]
@pytest.mark.parametrize("case", ["cover", "none"])
def test_거울_파이썬_TS(case):
daega = _covered_excel()
cover = COVER if case == "cover" else {}
py = json.loads(json.dumps(compute_labor(daega, b145.SHEETS, b145.SANGEUN, cover)))
ts = b145._ts(daega, b145.SHEETS, b145.SANGEUN, cover)
assert b145._same(py, ts["out"])
assert b145._same(daega_price(py, "pipe@100.0"), ts["price"])
def test_노임_새_판_덮개_칸_그대로():
"""702-4 — 바탕 단가가 새 판으로 바뀌어도 고친 칸 = 그대로 · 안 고친 칸만 새 값."""
daega = _covered_excel()
cover = {"T:DG9": {"labor:L00005#0": {"qty": 0.12}, "labor:L00017#1": {"labor": 170000}}}
old = compute_labor(daega, b145.SHEETS, b145.SANGEUN, cover)
new_sheets = {
**b145.SHEETS,
"labor": [{**r, "price": round(r["price"] * 1.05)} for r in b145.SHEETS["labor"]],
}
new = compute_labor(daega, new_sheets, b145.SANGEUN, cover)
a, b = _line(old, "DG9", 0), _line(new, "DG9", 0)
assert a["qty"] == b["qty"] == 0.12 # 고친 품 그대로
assert b["prices"]["labor"] == round(258935 * 1.05) != a["prices"]["labor"] # 안 고친 단가 = 새
assert b["base"]["labor"] == b["prices"]["labor"] # 품셈 값 곁 = 새 판 값
c, d = _line(old, "DG9", 1), _line(new, "DG9", 1)
assert c["prices"]["labor"] == d["prices"]["labor"] == 170000 # 고친 단가 그대로
assert d["qty"] == 0.03 and d["base"]["labor"] == round(165545 * 1.05)
assert new["daega_orphans"] == []
# ── 사본 → 덮개 열쇠 ───────────────────────────────────────────────────────
@pytest.fixture()
def project(tmp_path) -> Path:
root = b145._project(
tmp_path / "proj", [(100, "SS000901"), (200, "SS000901"), (400, "SS000902")]
)
b145._snap(root, "pipe@100.0", "SS000901", b145.LINES, b145._values(b145.STONE))
b145._snap(root, "pipe@200.0", "SS000901", b145.LINES, b145._values(b145.STONE))
promoted = [
{"로직": "GF000219", "승격": True, "수량": {"손": 1}},
{"로직": "GF000119", "승격": True, "수량": {"손": 1}},
{"참조": {"층": "system", "주인": None, "키": "SC-T"}, "수량": {"손": 4}},
]
b145._snap(root, "pipe@400.0", "SS000902", promoted, b145._values(b145.STONE), b145.SC)
return root
def test_사본_덮개_열쇠(project):
got = daega_inputs(project)
blocks = {b["id"]: b for b in got["blocks"]}
one = blocks[got["owners"]["pipe@100.0"]]
assert one is blocks[got["owners"]["pipe@200.0"]] # 같은 꼴 = 한 호표 · 처음 본 사본 열쇠
assert one["cover"] == f"S:{snapshots.folder_name('pipe@100.0')}"
assert [ln["cid"] for ln in one["lines"]] == [
"sangeun:SG:GF000219#0",
"sangeun:SG:GF000119#0",
"sangeun:SG:GF000143#0",
]
four = blocks[got["owners"]["pipe@400.0"]]
assert [ln["cid"] for ln in four["lines"]] == [
"daega:GF000219#0",
"daega:GF000119#0",
"daega:참조|system||SC-T#0",
]
stone = blocks[four["lines"][0]["key"]]
assert stone["cover"].startswith("P:") and [ln["cid"] for ln in stone["lines"]] == [
"labor:LB000033#0",
"labor:LB000002#0",
]
assert blocks[four["lines"][2]["key"]]["cover"] == "C:참조|system||SC-T"
def test_재적용_뒤_덮개_남음(project):
"""702-4 — 사본을 다시 만들어(재적용 꼴 · 수량 · 줄 차례 바뀜 · 줄 빠짐) 블록 id 가 바뀌어도
cover · cid 가 같아 고친 칸이 맞는 줄에 붙음 · 사라진 줄 덮개 = 짝 잃음(남김)."""
before = daega_inputs(project)
owner = before["owners"]["pipe@100.0"]
first = next(b for b in before["blocks"] if b["id"] == owner)
cover = {
first["cover"]: {
"sangeun:SG:GF000119#0": {"qty": 0.7},
"sangeun:SG:GF000143#0": {"labor": 1000},
}
}
folder = snapshots.structures_dir(project) / snapshots.folder_name("pipe@100.0")
doc = json.loads((folder / "일위대가.json").read_text(encoding="utf-8"))
lines = doc["줄"]["담은로직"]
lines[0]["수량"] = {"손": 5} # 설계 바뀜(수량)
doc["줄"]["담은로직"] = [lines[1], lines[0]] # 차례 바뀜 · GF000143 빠짐
(folder / "일위대가.json").write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8")
after = daega_inputs(project)
block = next(b for b in after["blocks"] if b["id"] == after["owners"]["pipe@100.0"])
assert block["id"] != owner and block["cover"] == first["cover"]
cids = [ln["cid"] for ln in block["lines"]]
assert cids == ["sangeun:SG:GF000119#0", "sangeun:SG:GF000219#0"]
sheets = {"labor": [], "material": [], "machine": [], "gaps": []}
out = compute_labor(after, sheets, {}, cover)
got = next(b for b in out["daega_blocks"] if b["id"] == block["id"])
assert got["lines"][0]["qty"] == 0.7 and got["lines"][0]["covered"] == ["qty"] # 맞는 줄
assert got["lines"][1]["qty"] == 5 and "covered" not in got["lines"][1]
assert out["daega_orphans"] == [{"cover": first["cover"], "cid": "sangeun:SG:GF000143#0"}]
def test_저장_다듬기():
raw = {
"daega_cover": {
"S:a": {"labor:L1#0": {"qty": 0.5, "labor": "x", "memo": 1}, "b#0": {"qty": None}},
"S:빈": {},
"S:c": "글",
"P:d": {"x#0": {"expense": float("inf"), "material": 3}},
}
}
assert store.clean_user(raw)["daega_cover"] == {
"S:a": {"labor:L1#0": {"qty": 0.5}},
"P:d": {"x#0": {"material": 3.0}},
}
assert "daega_cover" not in store.clean_user({"daega_cover": {"S:a": {}}})
def test_저장_뒤_서버_값(tmp_path):
"""[저장](write_user → read_user) 뒤 서버 셈 = 화면 짝 셈(덮개 입력값만 받고 다시 셈)."""
stored = store.write_user(tmp_path, {"daega_cover": COVER})
assert store.read_user(tmp_path)["daega_cover"] == stored["daega_cover"] == COVER
daega = _covered_excel()
server = compute_labor(daega, b145.SHEETS, b145.SANGEUN, stored["daega_cover"])
screen = b145._ts(daega, b145.SHEETS, b145.SANGEUN, COVER)["out"]
assert b145._same(json.loads(json.dumps(server)), screen)
def test_B08_카드_고친_칸_수(project):
"""702-4 B08 구조물도 카드 — 그 사본 블록 + 부른 블록의 덮개 수(카드 금액 = 사본 셈)."""
got = daega_inputs(project)
four = next(b for b in got["blocks"] if b["id"] == got["owners"]["pipe@400.0"])
stone = next(b for b in got["blocks"] if b["id"] == four["lines"][0]["key"])
store.write_user(
project,
{
"daega_cover": {
stone["cover"]: {"labor:LB000033#0": {"qty": 0.3}},
"S:없음": {"x#0": {"qty": 1}},
}
},
)
assert cover_count(project, snapshots.folder_name("pipe@400.0")) == 1 # 부른 품셈 블록
assert cover_count(project, snapshots.folder_name("pipe@100.0")) == 0