feat(719-4 · 719-3): B09 단가산출근거 탭 = 공용 계산서 카드 · 이 공사 산근 덮개

- 719-4 근거 탭 = 산근 블록마다 카드 · 공용 calcSheetRows 묶음 꼴(인력 · 재료 · 경비(장비 한 줄 + 중기사용료 펼침 넉 줄) · 하위 = 산근 n호표 한 줄) · 서버 줄 덧칸 sheet{쓰는입력 · 기준단위 · 환산근거}(master_sheet_line 한 벌) · 블록 기준단위 · 입력 · 중간값 글은 고치기 펼침 때 · 옛 풀이표는 곁 창에만
- 719-3 산근 덮개 sg_cover {산근 키: {「n:이름」: {qty · 노 · 재 · 경 단가}}} · 셈 짝 B09_Estimation_Sangeun_Cover.py ↔ .ts(서버 풀이 뒤 덧씌움 · 하위 산근 이어짐 · % 덧줄 밑수 비례 · 총계 · 목록표 · 빈칸 목록 · raw 로 두 번 덮어도 같음 · 짝 잃음 sg_orphans 알림)
- 고침 표(702 꼴) = 품 · 단가 두 번 눌러 고침 · 고친 칸 색 · 원래 값 칸 · 「이 공사에서 고침」 꼬리표 · 우클릭 · 카드 단추 [단가산출 값으로] · 고친 뒤 보던 자리 지킴
- 저장 다듬기 sg_cover · [업데이트] 알림 고친 칸 수에 산근 칸 · 셈 길(compute_sheets) · 화면 짝(computeSheets) 둘 다 덮개
- 금액 전후 같음 — 덮개 없음 = 서버 풀이 그대로 · 덧칸 뺀 옛 풀이와 13 블록 같음 · 일위대가 합 검증_랩탑메인 13,662,560 · 검증_sub5 10,149,562 그대로
- 시험 새로 test_719_3_sangeun_cover(8 · 거울 · 대가 · 원가계산서 · 업데이트 뒤 지킴) · test_719_4_sangeun_sheet(2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-01 17:28:11 +09:00
co-authored by Claude Opus 5.5
parent 54ab449a9c
commit 51f07499f5
11 changed files with 1127 additions and 6 deletions
+4 -1
View File
@@ -79,6 +79,7 @@ from B09_Estimation import B09_Estimation_Past as past
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_Sangeun_Cover as sg_cover
from B09_Estimation import B09_Estimation_Store as store
from B09_Estimation.B09_Estimation_Bill_Items import bill_items, hopyo_prices
from B09_Estimation.B09_Estimation_Calc_Base import compute_base
@@ -159,6 +160,7 @@ def compute_sheets(
memo = 두 번 셈의 앞 산근 풀이(`Sangeun.collect`)."""
base = compute_base(inputs, user)
sg = sangeun.build(root, base, user, memo) # 145-1 — 굳힌 단가로 풀이(+ 166-2 손 줄 로직)
sg = sg_cover.cover_sangeun(sg, user.get("sg_cover")) # 719-3 이 공사 산근 고친 칸
by_key = {row["key"]: row for row in sg["sg_list"]}
labor = compute_labor(
inputs.get("daega"), {**base, "gaps": []}, by_key, user.get("daega_cover")
@@ -362,7 +364,8 @@ async def refresh_prices(
await asyncio.to_thread(store.write_book, root, book)
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"))
kept = draft or out.get("user") or {}
cells = refresh.cover_cells(kept.get("daega_cover")) + refresh.cover_cells(kept.get("sg_cover"))
out["notes"] = [
f"업데이트 — 단가 줄 {diff['new'] + diff['changed']} 바뀜 · 구조물 사본 "
f"{len(again['다시'])} 다시 가져옴 · 고친 칸 {cells} 그대로",
+28 -1
View File
@@ -26,6 +26,9 @@
텍스트 수식(155-5) = `B09_Estimation_Sangeun_Text`(같은 B09 단가) — `head`(머리 · 입력 · 중간) ·
줄 `text`(줄마다 · 중기 = 노 · 재 · 경 세 줄) · `foot`(계) · % 덧줄 = 규격 「노무비의 %」 ·
수량 = 율 · 단위 %(149-4 `pct_extras`).
계산서 줄 덧칸(719-4) = 줄 `sheet`{쓰는입력 · 기준단위 · 환산근거} · 블록 `기준단위` —
화면 공용 계산서(`B09_Estimation_UI_Tabs_Sangeun_Sheet`) ·
덮개(719-3) = `B09_Estimation_Sangeun_Cover`(풀이 뒤 덧씌움).
"""
from __future__ import annotations
@@ -55,8 +58,11 @@ from M02_MasterTemplete.M02_Item_Snapshot import (
from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, PRICE_FILE, read_json
mf = item_prices.mf
import master_sheet_line as msl # noqa: E402 — 계산서 줄 덧칸(714-1) · scripts 길은 `m01` 이 둠
PARTS = ("labor", "material", "expense")
COST_PART = {"노무비": "labor", "재료비": "material", "경비": "expense"}
PCT_OF = {**COST_PART, "인건비": "people"} # % 덧줄 밑수 → 덮개 다시 셈(719-3)
DEPTH = 4 # 하위 산근 깊이 상한
_Q = re.compile(r"Q\d*") # 작업량 중간값 이름(산림품셈 09장 토공 · 07장 집재 …)
NO_SOURCE = "조합 밖 토공 · 운반 · 사토 · 준비공은 산근 없음"
@@ -370,6 +376,7 @@ class Solver:
for n, item in enumerate(row.get("호표", [])):
lines.append(self._line(whole, item, n, env, times, rate, depth))
self._extras(whole, row, env, lines)
_annotate(whole, row, env, lines)
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e:
return self._stop(code, name, str(e) or type(e).__name__)
sums = block_totals(lines)
@@ -388,6 +395,7 @@ class Solver:
"why": why,
"head": head,
"foot": foot,
"기준단위": msl._per_text(msl.mbs.result_per(row.get("결과단위"))) or None,
}
@staticmethod
@@ -475,7 +483,8 @@ class Solver:
spec = goods.get("규격") or ""
ref = {"tab": "compare", "key": key}
return {**base, **zero, "spec": spec, "kind": "material", "ref": ref,
"price": {part: price}, part: _money(price, q), "why": why} # fmt: skip
"price": {part: price}, part: _money(price, q), "why": why,
"_goods": goods} # fmt: skip
def _logic_line(self, whole, item, env, q, base, zero, depth) -> dict[str, Any]:
call = call_of(whole, item, env)
@@ -510,6 +519,8 @@ class Solver:
"unit": "식", "ref": None, **dict.fromkeys(PARTS, 0.0)} # fmt: skip
if "율" in pct:
line.update(spec=pct.get("규격") or extra["식"], qty=float(pct["율"]), unit="%")
if of := PCT_OF.get(str(pct.get("규격") or "").removesuffix("의 %")):
line["of"] = of # 719-3 덮개 다시 셈 밑수(`Sangeun_Cover`)
if any(v is None for v in sums.values()):
lines.append({**line, part: None, "price": {}, "why": "밑수 빈칸"})
continue
@@ -527,6 +538,22 @@ class Solver:
lines.append({**line, "price": {part: value}, part: rounddown(value, 1), "why": None})
def _annotate(whole: Any, row: dict, env: dict, lines: list[dict[str, Any]]) -> None:
"""719-4 계산서 줄 덧칸 `sheet`{쓰는입력 · 기준단위 · 환산근거} — `master_sheet_line` 한 벌
(M01 `/text` · M02 `/logic-lib/sheet` 와 같은 뜻) · 묶음 · 장비 = 줄 `kind` 로 화면이 가름."""
per = msl.mbs.result_per(row.get("결과단위"))
items = [*row.get("호표", []), *row.get("덧줄", [])]
for item, ln in zip(items, lines):
goods = ln.pop("_goods", None) or {}
exprs = [item["식"]] if "식" in item else [item.get("수량"), item.get("요소")]
seen = {"품목": goods, "단위": item.get("단위")}
try:
got = msl._fields(whole, row, exprs, dict(env), per, seen, None)
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError):
got = {}
ln["sheet"] = got
# ── 모음 ──────────────────────────────────────────────────────────────
LISTED = ("no", "key", "logic", "name", "spec", "spec_raw", "unit", "sum", *PARTS, "uses")
@@ -0,0 +1,203 @@
"""B09 산근 덮개(PLAN 719-3) — 이 공사 단가산출근거 고친 칸(품 · 단가)을 서버 풀이 뒤에 덧씌움.
⚠ 짝 = `B09_Estimation_Sangeun_Cover.ts`(같은 식 · 차례 · 끝수) — 한쪽을 고치면 다른 쪽도 ·
거울 시험 `resources/tester/test_719_3_sangeun_cover.py`.
조작 중 = 화면이 TS 짝으로 바로 셈 · [저장] = 서버가 다시 풀이(`Sangeun.build`) 뒤 이 파일로 정본.
덮개 = 사용자 값 `sg_cover` {산근 키: {줄 열쇠 「n:이름」: {qty?, labor?, material?, expense?}}} —
qty = 1 단위 품 · 비목 = 단가 · 있는 칸만 셈 값 대신 · 줄이 없거나 이름이 다르면 짝 잃음
(`sg_orphans` · 알림만 · 덮개는 남음) · 산근 키 = 로직 + 입력값 → [업데이트] 뒤에도 같은 블록.
고칠 칸(`fields`) = 노무 · 자재 · 중기 · 빈 줄 = 품 + 쓰는 비목 단가 · 하위 산근 = 품 ·
% 덧줄(`of` 있는 것) = 율.
다시 셈 = 덮개가 닿은 블록과 그 블록을 부르는 블록만(안 닿은 블록 = 서버 값 그대로) —
줄 금액 ROUNDDOWN(단가 × 품, 1) · 하위 산근 줄 단가 = 다시 센 그 블록 비목 합 ·
% 덧줄 = 서버 값 × (지금 밑수 ÷ 처음 밑수) × (지금 율 ÷ 처음 율) · 처음 밑수 · 율이 0 · 빈칸이면
밑수 × 율 ÷ 100(밑수 = 앞 줄 그 비목 합 · `people` = 노무 줄 노무비 합) ·
총계 = 비목마다 ROUNDDOWN(Σ, 0) · 합계 = 셋 합 · 목록표(`sg_list`) · 빈칸 목록(`sg_gaps`) 같이.
율 식 밖 덧줄(`of` 없음 · 기계손료 · 조건식) = 서버 값 그대로.
ponytail: 인건비 % 덧줄 = 노무 줄 합 비례(식이 일부 직종만 부르면 어긋남 · 저장 뒤도 같은 값)
고친 줄 = `base`(덮기 전 {qty, 고칠 비목 단가}) · `covered`(고친 칸) ·
목록표 줄 `covered`(고친 줄 수) · 다시 센 블록 · 목록 줄 `raw` = 덮기 전 값(두 번 덮어도 같음 —
서버 답이 덮인 채 오면 화면 짝이 벗기고 다시 덮음).
"""
from __future__ import annotations
import copy
from typing import Any
from B09_Estimation.B09_Estimation_Calc_Base import gap, num, rounddown, total
PARTS = ("labor", "material", "expense")
COVER_KEYS = ("qty", *PARTS)
DEPTH = 8 # 하위 산근 깊이 상한(서버 풀이 4 보다 넉넉히)
def line_id(n: int, line: dict[str, Any]) -> str:
"""덮개 줄 열쇠 — 블록 안 차례 + 이름(로직 개정으로 줄이 밀리면 짝 잃음)."""
return f"{n}:{line.get('name') or ''}"
def fields(line: dict[str, Any]) -> list[str]:
"""고칠 수 있는 칸 — 품(qty) + 단가 비목."""
kind = line.get("kind")
if kind == "sangeun":
return ["qty"]
if kind == "extra":
return ["qty"] if line.get("of") else []
if kind == "machine":
return ["qty", *PARTS]
if kind == "labor":
return ["qty", "labor"]
if kind == "material":
return ["qty", *[p for p in PARTS if p in (line.get("price") or {})]]
if kind == "blank":
return ["qty", *[p for p in PARTS if line.get(p) is None]]
return []
def _money(price: float | None, qty: float | None) -> float | None:
return None if price is None or qty is None else rounddown(price * qty, 1)
def _base(lines: list[dict[str, Any]], n: int, of: str) -> float | None:
"""% 덧줄 밑수 — 앞 줄(0 … n-1) 비목 합 · people = 노무 줄 노무비 합."""
if of == "people":
return total([ln.get("labor") for ln in lines[:n] if ln.get("kind") == "labor"])
return total([ln.get(of) for ln in lines[:n]])
def _settle(block: dict[str, Any], mine: dict[str, Any], by: dict[str, dict[str, Any]]) -> None:
"""블록 한 장 다시 셈 — 덮개 칸 · 하위 산근 새 합 · % 덧줄 · 총계."""
first = copy.deepcopy(block["lines"])
for n, ln in enumerate(block["lines"]):
cell = mine.get(line_id(n, ln)) or {}
can = fields(ln)
keys = [k for k in can if num(cell.get(k)) is not None]
qty0, price0 = ln.get("qty"), dict(ln.get("price") or {})
if keys:
ln["base"] = {"qty": qty0, **{p: price0.get(p) for p in can if p != "qty"}}
ln["covered"] = keys
qty = num(cell["qty"]) if "qty" in keys else qty0
kind = ln.get("kind")
if kind == "sangeun":
sub = by.get((ln.get("ref") or {}).get("key")) or {}
price = {p: sub.get(p) for p in PARTS}
ln.update(qty=qty, price=price, **{p: _money(price[p], qty) for p in PARTS})
ln["why"] = (
None if sub.get("sum") is not None else f"하위 산근 {sub.get('no', '')} 빈칸"
)
elif kind == "extra":
of = ln.get("of")
part = next(iter(price0), None) or next((p for p in PARTS if ln.get(p) is None), None)
if not of or part is None:
continue
now, then, value0 = _base(block["lines"], n, of), _base(first, n, of), price0.get(part)
if now is None or qty is None:
value = None
elif value0 is not None and then and qty0:
value = value0 * (now / then) * (qty / qty0)
else:
value = now * qty / 100
ln.update(qty=qty, price={part: value}, **{part: _money(value, 1)})
ln["why"] = None if value is not None else "밑수 빈칸"
else:
price = {**price0, **{p: num(cell[p]) for p in keys if p != "qty"}}
for p in can:
if p != "qty":
ln[p] = _money(price.get(p), qty)
ln.update(qty=qty, price=price)
if all(ln.get(p) is not None for p in PARTS):
ln["why"] = None
for part in PARTS:
s = total([ln.get(part) for ln in block["lines"]])
block[part] = None if s is None else rounddown(s, 0)
block["sum"] = total([block[p] for p in PARTS])
block["why"] = next((ln["why"] for ln in block["lines"] if ln.get("why")), None)
def _orphans(by: dict[str, dict[str, Any]], cover: dict[str, Any]) -> list[dict[str, str]]:
out = []
for key, lines in cover.items():
block = by.get(key)
for lid in lines if isinstance(lines, dict) else {}:
n, _, name = str(lid).partition(":")
ln = (
block["lines"][int(n)]
if block and n.isdigit() and int(n) < len(block["lines"])
else None
)
if not ln or (ln.get("name") or "") != name or not fields(ln):
out.append({"key": key, "line": str(lid)})
return out
def _bare(row: dict[str, Any]) -> dict[str, Any]:
"""덮인 목록 줄 → 덮기 전 값(`raw`)."""
if "raw" not in row:
return row
return {**{k: v for k, v in row.items() if k not in ("raw", "covered")}, **row["raw"]}
def _gaps(blocks: list[dict[str, Any]]) -> list[dict[str, str]]:
return [
gap("sg", b["key"], f"산근 {b['no']} {b['name']}", b["why"] or "단가 빈칸")
for b in blocks
if b["sum"] is None
]
def cover_sangeun(sg: dict[str, Any], cover: Any) -> dict[str, Any]:
"""산근 답(`Sangeun.collect` 꼴) + 덮개 → 새 답(받은 답 안 고침) · 덮개 없으면 그대로 ·
이미 덮인 답(서버 셈 답) = 블록 · 목록 줄 `raw`(덮기 전 값)로 벗긴 뒤 다시 덮음."""
cover = cover if isinstance(cover, dict) else {}
worn = sg.get("sg") or []
blocks = [b["raw"] if "raw" in b else b for b in worn]
orphans = _orphans({b["key"]: b for b in blocks}, cover)
if not any(cover.get(b["key"]) for b in blocks):
if not any("raw" in b for b in worn):
return {**sg, "sg_orphans": orphans}
rows = [_bare(r) for r in sg.get("sg_list") or []]
return {
**sg,
"sg": blocks,
"sg_list": rows,
"sg_gaps": _gaps(blocks),
"sg_orphans": orphans,
}
out = copy.deepcopy(blocks)
by = {b["key"]: b for b in out}
hit: dict[str, bool] = {}
def touch(key: str, depth: int) -> bool:
if key in hit:
return hit[key]
block = by.get(key)
if block is None or depth > DEPTH:
return False
hit[key] = False # 돎 막음
subs = [
touch(ln["ref"]["key"], depth + 1)
for ln in block["lines"]
if ln.get("kind") == "sangeun" and (ln.get("ref") or {}).get("key")
]
hit[key] = bool(cover.get(key)) or any(subs)
if hit[key]:
raw = copy.deepcopy(block)
_settle(block, cover.get(key) or {}, by)
block["raw"] = raw
return hit[key]
for b in out:
touch(b["key"], 0)
listing = []
for row in map(_bare, sg.get("sg_list") or []):
b = by.get(row["key"])
if b is None or not hit.get(row["key"]):
listing.append(row)
continue
n = sum(1 for ln in b["lines"] if ln.get("covered"))
sums = {k: b[k] for k in (*PARTS, "sum")}
raw = {k: row.get(k) for k in (*PARTS, "sum")}
listing.append({**row, **sums, **({"covered": n} if n else {}), "raw": raw})
return {**sg, "sg": out, "sg_list": listing, "sg_gaps": _gaps(out), "sg_orphans": orphans}
@@ -0,0 +1,185 @@
/* =============================================================================
* B09_Estimation_Sangeun_Cover.ts
* B09 산근 덮개(PLAN 719-3) — 이 공사 단가산출근거 고친 칸(품 · 단가)을 서버 풀이 뒤에 덧씌움.
*
* ⚠ 짝 = `B09_Estimation_Sangeun_Cover.py`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 ·
* 거울 시험 `resources/tester/test_719_3_sangeun_cover.py`.
* 조작 중 = 이 파일이 바로 셈 · [저장] = 서버가 다시 풀이(`Sangeun.build`) 뒤 파이썬 짝으로 정본.
*
* 덮개 = 사용자 값 `sg_cover` {산근 키: {줄 열쇠 「n:이름」: {qty?, labor?, material?, expense?}}} —
* 있는 칸만 셈 값 대신 · 줄이 없거나 이름이 다르면 짝 잃음(`sg_orphans` · 알림만)
* 고칠 칸(`fields`) = 노무 · 자재 · 중기 · 빈 줄 = 품 + 쓰는 비목 단가 · 하위 산근 = 품 · % 덧줄(`of`) = 율
* 다시 셈 = 덮개가 닿은 블록과 그 블록을 부르는 블록만 — 줄 금액 ROUNDDOWN(단가 × 품, 1) ·
* 하위 산근 줄 단가 = 다시 센 그 블록 비목 합 · % 덧줄 = 서버 값 × 밑수 비 × 율 비(처음이 0 · 빈칸이면
* 밑수 × 율 ÷ 100) · 총계 = 비목마다 ROUNDDOWN(Σ, 0) · 목록표 · 빈칸 목록 같이
* · 율 식 밖 덧줄(`of` 없음) = 서버 값 그대로
* 고친 줄 = `base`(덮기 전 값) · `covered`(고친 칸) · 다시 센 블록 · 목록 줄 `raw` = 덮기 전 값
* (두 번 덮어도 같음 — 서버 답이 덮인 채 오면 벗기고 다시 덮음)
* ========================================================================== */
import { gap, num, rounddown, total, type Num, type Row } from "./B09_Estimation_Calc_Base";
const PARTS = ["labor", "material", "expense"] as const;
export const SG_COVER_KEYS = ["qty", ...PARTS] as const;
const DEPTH = 8;
/** 덮개 줄 열쇠 — 블록 안 차례 + 이름 */
export const lineId = (n: number, line: Row): string => `${n}:${line.name || ""}`;
/** 고칠 수 있는 칸 — 품(qty) + 단가 비목 */
export function fields(line: Row): string[] {
const kind = line.kind;
if (kind === "sangeun") return ["qty"];
if (kind === "extra") return line.of ? ["qty"] : [];
if (kind === "machine") return ["qty", ...PARTS];
if (kind === "labor") return ["qty", "labor"];
if (kind === "material") return ["qty", ...PARTS.filter((p) => p in (line.price || {}))];
if (kind === "blank") return ["qty", ...PARTS.filter((p) => (line[p] ?? null) === null)];
return [];
}
const money = (price: Num | undefined, qty: Num | undefined): Num =>
price === null || price === undefined || qty === null || qty === undefined
? null
: rounddown(price * qty, 1);
function baseOf(lines: Row[], n: number, of: string): Num {
if (of === "people")
return total(
lines
.slice(0, n)
.filter((l) => l.kind === "labor")
.map((l) => l.labor ?? null),
);
return total(lines.slice(0, n).map((l) => l[of] ?? null));
}
function settle(block: Row, mine: Row, by: Map<string, Row>): void {
const first: Row[] = JSON.parse(JSON.stringify(block.lines));
(block.lines as Row[]).forEach((ln, n) => {
const cell: Row = mine[lineId(n, ln)] || {};
const can = fields(ln);
const keys = can.filter((k) => num(cell[k]) !== null);
const qty0: Num = ln.qty ?? null;
const price0: Row = { ...(ln.price || {}) };
if (keys.length) {
ln.base = {
qty: qty0,
...Object.fromEntries(can.filter((p) => p !== "qty").map((p) => [p, price0[p] ?? null])),
};
ln.covered = keys;
}
const qty: Num = keys.includes("qty") ? num(cell.qty) : qty0;
if (ln.kind === "sangeun") {
const sub: Row = by.get((ln.ref || {}).key) || {};
const price = Object.fromEntries(PARTS.map((p) => [p, sub[p] ?? null]));
Object.assign(ln, { qty, price }, ...PARTS.map((p) => ({ [p]: money(price[p], qty) })));
ln.why = (sub.sum ?? null) !== null ? null : `하위 산근 ${sub.no ?? ""} 빈칸`;
} else if (ln.kind === "extra") {
const of: string | undefined = ln.of;
const part =
Object.keys(price0)[0] ?? PARTS.find((p) => (ln[p] ?? null) === null) ?? undefined;
if (!of || part === undefined) return;
const now = baseOf(block.lines, n, of);
const then = baseOf(first, n, of);
const value0: Num = price0[part] ?? null;
let value: Num;
if (now === null || qty === null) value = null;
else if (value0 !== null && then && qty0)
value = value0 * (now / then) * ((qty as number) / qty0);
else value = (now * (qty as number)) / 100;
Object.assign(ln, { qty, price: { [part]: value }, [part]: money(value, 1) });
ln.why = value !== null ? null : "밑수 빈칸";
} else {
const price: Row = {
...price0,
...Object.fromEntries(keys.filter((p) => p !== "qty").map((p) => [p, num(cell[p])])),
};
for (const p of can) if (p !== "qty") ln[p] = money(price[p] ?? null, qty);
Object.assign(ln, { qty, price });
if (PARTS.every((p) => (ln[p] ?? null) !== null)) ln.why = null;
}
});
for (const part of PARTS) {
const s = total((block.lines as Row[]).map((l) => l[part] ?? null));
block[part] = s === null ? null : rounddown(s, 0);
}
block.sum = total(PARTS.map((p) => block[p]));
block.why = (block.lines as Row[]).find((l) => l.why)?.why ?? null;
}
function orphansOf(by: Map<string, Row>, cover: Row): Row[] {
const out: Row[] = [];
for (const [key, lines] of Object.entries(cover)) {
const block = by.get(key);
const ids = lines && typeof lines === "object" ? Object.keys(lines) : [];
for (const lid of ids) {
const at = lid.indexOf(":");
const n = at < 0 ? lid : lid.slice(0, at);
const name = at < 0 ? "" : lid.slice(at + 1);
const ok = /^\d+$/.test(n) && block && Number(n) < block.lines.length;
const ln: Row | null = ok ? block!.lines[Number(n)] : null;
if (!ln || (ln.name || "") !== name || !fields(ln).length) out.push({ key, line: lid });
}
}
return out;
}
/** 산근 답(sg · sg_list · sg_gaps …) + 덮개 → 새 답(받은 답은 안 고침) · 덮개 없으면 받은 값 그대로 */
const bare = (row: Row): Row => {
if (!("raw" in row)) return row;
const { raw, covered: _covered, ...rest } = row;
return { ...rest, ...raw };
};
const gapsOf = (blocks: Row[]): Row[] =>
blocks
.filter((b) => b.sum === null)
.map((b) => gap("sg", b.key, `산근 ${b.no} ${b.name}`, b.why || "단가 빈칸"));
/** 이미 덮인 답(서버 [저장] · 셈 답) = 블록 · 목록 줄 `raw`(덮기 전 값)로 벗긴 뒤 다시 덮음 */
export function coverSangeun(sg: Row, cover: unknown): Row {
const covers: Row = cover && typeof cover === "object" ? (cover as Row) : {};
const worn: Row[] = sg.sg || [];
const blocks: Row[] = worn.map((b) => ("raw" in b ? b.raw : b));
const orphans = orphansOf(new Map(blocks.map((b) => [b.key, b])), covers);
if (!blocks.some((b) => covers[b.key] && Object.keys(covers[b.key]).length)) {
if (!worn.some((b) => "raw" in b)) return { ...sg, sg_orphans: orphans };
const rows = ((sg.sg_list || []) as Row[]).map(bare);
return { ...sg, sg: blocks, sg_list: rows, sg_gaps: gapsOf(blocks), sg_orphans: orphans };
}
const out: Row[] = JSON.parse(JSON.stringify(blocks));
const by = new Map(out.map((b) => [b.key as string, b]));
const hit = new Map<string, boolean>();
const touch = (key: string, depth: number): boolean => {
if (hit.has(key)) return hit.get(key)!;
const block = by.get(key);
if (!block || depth > DEPTH) return false;
hit.set(key, false); // 돎 막음
const subs = (block.lines as Row[])
.filter((l) => l.kind === "sangeun" && (l.ref || {}).key)
.map((l) => touch(l.ref.key, depth + 1));
const mine: Row = covers[key] || {};
const got = Object.keys(mine).length > 0 || subs.some(Boolean);
hit.set(key, got);
if (got) {
const raw = JSON.parse(JSON.stringify(block));
settle(block, mine, by);
block.raw = raw;
}
return got;
};
for (const b of out) touch(b.key, 0);
const listing = ((sg.sg_list || []) as Row[]).map(bare).map((row) => {
const b = by.get(row.key);
if (!b || !hit.get(row.key)) return row;
const n = (b.lines as Row[]).filter((l) => l.covered).length;
return {
...row,
...Object.fromEntries([...PARTS, "sum"].map((k) => [k, b[k]])),
...(n ? { covered: n } : {}),
raw: Object.fromEntries([...PARTS, "sum"].map((k) => [k, row[k] ?? null])),
};
});
return { ...sg, sg: out, sg_list: listing, sg_gaps: gapsOf(out), sg_orphans: orphans };
}
+4
View File
@@ -12,6 +12,8 @@
— 고친 칸만(키가 있으면 그 값 · null = 빈칸 · 없으면 기본)
"daega_cover": {블록 cover: {줄 cid: {qty?, labor?, material?, expense?}}}
(702-2 일위대가 고친 칸 덮개 — 수만 · 셈 = `B09_Estimation_Calc_Labor`)
"sg_cover": {산근 키: {줄 「n:이름」: {qty?, labor?, material?, expense?}}}
(719-3 산근 고친 칸 — 셈 = `B09_Estimation_Sangeun_Cover`)
+ 복사본 `K:…` 의 `@copy` {from · name · spec}(703-2 · 글만)
+ 붙인 줄 `@add` [블록 줄 fixed · pct](705-2 · `clean_added`)
"labor_cover": {노무 키: 일당} · "machine_cover": {기계 키: {labor,
@@ -353,6 +355,8 @@ def clean_user(raw: Any) -> dict[str, Any]:
out = {"materials": materials, "estimate": estimate, **({"bill": bill} if bill else {})}
if cover := clean_cover(raw.get("daega_cover")):
out["daega_cover"] = cover
if cover := clean_cover(raw.get("sg_cover")): # 719-3 산근 고친 칸(같은 꼴 · 줄 「n:이름」)
out["sg_cover"] = cover
given = raw.get("labor_cover") if isinstance(raw.get("labor_cover"), dict) else {}
if labor := {str(k)[:200]: _num(v) for k, v in given.items() if _num(v) is not None}:
out["labor_cover"] = labor
+22
View File
@@ -314,3 +314,25 @@ html.is-bm-route .b09-estimation > .b09-estimation__tabs:first-child {
color: var(--color-danger);
font-weight: var(--font-weight-bold);
}
/* 719-4 단가산출근거 = 산근 카드(공용 계산서 줄 세로) · 719-3 이 공사에서 고친 줄 = 고친 칸 색 */
.b09-sg-sheets {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
overflow: auto;
overflow-anchor: none; /* 카드를 갈아 끼워도 보던 자리(고치기 펼침) */
min-height: 0;
}
.b09-sg-sheet {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
min-width: 0;
}
.b09-sg-sheet .is-edited {
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--color-warning) 22%, var(--color-canvas));
}
+12 -4
View File
@@ -54,6 +54,8 @@ import {
} from "./B09_Estimation_UI_Tabs";
import { attachRowMenu, openMenuAt, type BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit";
import { jumpRange, jumpTo, levelBar, pickedClass } from "./B09_Estimation_UI_Tabs_Bill_Level";
import { coverSangeun } from "./B09_Estimation_Sangeun_Cover";
import { renderSangeunSheets } from "./B09_Estimation_UI_Tabs_Sangeun_Sheet";
import {
isGroupTab,
lineHint,
@@ -100,10 +102,14 @@ const SERVER_ONLY = ["sg", "sg_list", "sg_gaps", "sg_notes"] as const;
/** 브라우저 셈 — 서버 짝과 같은 식(조작 중) · 산근은 서버 답 그대로(+ 「연결 없음」) */
export function computeSheets(payload: Row, user: Row): Row {
const server: Row = payload.sheets || {};
const kept: Row = {
...Object.fromEntries(SERVER_ONLY.map((k) => [k, server[k] ?? []])),
sg_hand: server.sg_hand ?? {},
};
// 719-3 산근 고친 칸 덮개 = 서버 풀이 뒤 덧씌움(짝 `B09_Estimation_Sangeun_Cover.py`)
const kept: Row = coverSangeun(
{
...Object.fromEntries(SERVER_ONLY.map((k) => [k, server[k] ?? []])),
sg_hand: server.sg_hand ?? {},
},
user.sg_cover,
);
// 대가(145-2) — 산근 단가는 서버 답 목록 그대로 · 내역(145-3)은 대가 단가로 블록마다
const sg = Object.fromEntries((kept.sg_list as Row[]).map((r) => [r.key, r]));
const labor = computeLabor(
@@ -189,6 +195,8 @@ export function renderB09Estimation(root: HTMLElement): void {
if (!projectId) return void panel.replaceChildren(note("프로젝트를 먼저 고르세요."));
if (!payload) return void panel.replaceChildren(note("원가 자료를 불러오는 중…"));
const ctx: TabContext = { sheets, payload, user, setUser };
// 719-4 단가산출근거 = 공용 계산서 카드(옛 표 꼴은 곁 창에만)
if (tab === "sg") return void panel.replaceChildren(...renderSangeunSheets(ctx, focus));
const spec = tabTableOf(tab, ctx);
if (!spec) return void panel.replaceChildren(note(LATER[tab] ?? "다음 단계에서 채움"));
if (typeof spec === "string") return void panel.replaceChildren(note(spec));
@@ -8,6 +8,8 @@
* 노무비 · 재료비 · 경비 — 머리(제 n 호표 · 출처 · 입력 · 중간 Q) → 줄(중기 = 노 · 재 · 경
* 세 줄) → 총계(계) · 줄 누름 = 단가를 낸 탭 · 줄(노무비목록표 · 중기목록표 · 자재단가대비표 ·
* 하위 산근)
* 719-4 — 단가산출근거 탭 본 화면 = 공용 계산서 카드(`…_UI_Tabs_Sangeun_Sheet`) · 풀이표는 곁 창(행 누름
* 팝업)에만 씀
* ========================================================================== */
import type { DataRow } from "@ui/ui_template_data_table";
@@ -0,0 +1,333 @@
/* =============================================================================
* B09_Estimation_UI_Tabs_Sangeun_Sheet.ts
* B09 단가산출근거 탭(PLAN 719-4 · 719-3) — 산근 블록마다 카드 한 장 = 공용 「단가산출 계산서」(714-2
* `calcSheetRows` 묶음 꼴 · M01 「인력·재료·경비」 · M02 와 같은 꼴) + 품 · 단가 고침 표(702 꼴).
*
* 값 = 서버 풀이(`B09_Estimation_Sangeun`) + 이 공사 고친 칸 덮개(`sg_cover` · 짝 `…_Sangeun_Cover`) —
* 화면은 받은 줄을 계산서 줄 꼴로 옮기기만(셈 없음)
* 계산서 = 노무비 · 재료비 · 경비 묶음 → 공용 부품이 인력 · 재료 · 경비(장비) · 하위로 다시 묶음 ·
* 중기 줄 = 장비 한 줄(노 · 재 · 경) + 펼침(중기사용료 블록 손료 · 운전사 · 연료 · 잡품) ·
* 하위 산근 = 「산근 n호표」 한 줄 · 묶음 머리 = 기준단위 · 환산 근거(서버 줄 덧칸 `sheet`)
* 고침(719-3) = [품 · 단가 고치기] 표에서 두 번 눌러 고침 → `sg_cover` 캐시 · 짝이 바로 다시 셈 ·
* [저장] 때 서버가 정본으로 다시 셈 · 고친 칸 색(`is-edited`) + 「원래 값」 칸 + 「이 공사에서 고침」 ·
* 우클릭 · 카드 단추 [단가산출 값으로] = 덮개 지움(셈 값 = 단가산출 값) · 빈칸 입력도 되돌림
* 옛 표 꼴(`buildSg`)은 곁 창(행 누름 팝업)에만 남음
* ========================================================================== */
import { createBanner } from "@ui/ui_template_banner";
import { createCard } from "@ui/ui_template_card";
import type { MapContextMenuItem } from "@ui/ui_template_context_menu";
import {
cellColumn,
createDataTable,
fillViewport,
type DataRow,
} from "@ui/ui_template_data_table";
import { createButton, el } from "@ui/ui_template_elements";
import { attachHoverInfo } from "@ui/ui_template_hover_info";
import { createText } from "@ui/ui_template_text";
import { calcSheetRows, type SheetGroup, type SheetLine } from "../A00_Common/calc_sheet";
import type { Row } from "./B09_Estimation_Calc_Base";
import { fields, lineId } from "./B09_Estimation_Sangeun_Cover";
import { money, parseNumber, qty, text, type TabContext } from "./B09_Estimation_UI_Tabs";
import { attachRowMenu } from "./B09_Estimation_UI_Tabs_Bill_Edit";
const PARTS = ["labor", "material", "expense"] as const;
const COST: Record<string, string> = { labor: "노무비", material: "재료비", expense: "경비" };
export const TAG = "이 공사에서 고침";
const BACK = "단가산출 값";
const LABEL: Record<string, string> = {
qty: "품",
labor: "노무비",
material: "재료비",
expense: "경비",
};
const FIELD: Record<string, string> = {
qty: "qty",
labor_price: "labor",
material_price: "material",
expense_price: "expense",
};
const fmt = (v: unknown): string =>
typeof v === "number" ? v.toLocaleString("ko-KR", { maximumFractionDigits: 4 }) : "빈칸";
/* ── 순수 — 산근 블록 → 계산서 묶음 ── */
/** 줄이 쓰는 비목 */
export function partsOf(ln: Row): string[] {
if (ln.kind === "labor") return ["labor"];
if (ln.kind === "machine" || ln.kind === "sangeun") return [...PARTS];
const own = Object.keys(ln.price || {}).filter((p) => (PARTS as readonly string[]).includes(p));
if (own.length) return own;
const blank = PARTS.filter((p) => (ln[p] ?? null) === null);
return blank.length ? blank : ["material"];
}
/** 고친 줄 원래 값 글 — 「품 0.12 · 노무비 268,908」 */
export const baseText = (ln: Row): string =>
((ln.covered || []) as string[]).map((k) => `${LABEL[k]} ${fmt(ln.base?.[k])}`).join(" · ");
function lineText(ln: Row, part: string): string {
const back = ln.covered?.length ? ` (원래 ${baseText(ln)})` : "";
if (ln.kind === "extra")
return ln.unit === "%"
? `${String(ln.spec || "").replace("%", `${fmt(ln.qty)}%`)}${back} = `
: `${ln.spec || ""}${back} = `;
return `${fmt(ln.price?.[part] ?? null)} * ${fmt(ln.qty)}${back} = `;
}
/** 산근 블록 → 공용 계산서 묶음(노무비 · 재료비 · 경비) · 계 */
export function sheetGroups(
block: Row,
machines: Row[],
noOf: Record<string, number>,
): SheetGroup[] {
const byKey = new Map(machines.map((m) => [m.key as string, m]));
return PARTS.map((part): SheetGroup => {
const lines: SheetLine[] = [];
for (const ln of (block.lines || []) as Row[]) {
if (!partsOf(ln).includes(part)) continue;
const sub = ln.kind === "sangeun" ? noOf[ln.ref?.key] : undefined;
const name = `${ln.name || ""}${sub ? ` (산근 ${sub}호표)` : ""}${ln.covered?.length ? ` — ${TAG}` : ""}`;
const m = ln.kind === "machine" ? byKey.get(ln.ref?.key) : undefined;
lines.push({
이름: name,
글: lineText(ln, part),
금액: ln[part] ?? null,
...(ln.kind === "machine" ? { 묶음: "경비", 장비: true } : {}),
...(ln.kind === "sangeun" ? { 묶음: "하위" } : {}),
쓰는입력: ln.sheet?.쓰는입력 ?? [],
기준단위: ln.sheet?.기준단위 ?? null,
환산근거: ln.sheet?.환산근거 ?? "",
펼침: m
? {
키: m.key,
이름: `${m.name}${m.spec ? ` ${m.spec}` : ""}`,
단위: m.unit || "시간",
줄: ((m.lines || []) as Row[]).map((x) => ({
비목: COST[x.kind] ?? x.kind,
이름: x.name,
글: `${fmt(x.price)} * ${fmt(x.qty)}${x.unit === "%" ? "%" : ""} = `,
금액: x.amount ?? null,
})),
계: m.sum ?? null,
}
: null,
});
}
return { 비목: COST[part], 소계: block[part] ?? null, 줄: lines };
}).filter((g) => g.줄.length);
}
/* ── 순수 끝 ── */
const opened = new Set<string>(); // 고침 표 · 입력 펼친 블록(다시 그려도 그대로)
let lastTop = 0; // 고친 뒤 다시 그려도 보던 자리
function setCover(
ctx: TabContext,
key: string,
lid: string,
field: string,
value: number | null,
): void {
const all: Row = JSON.parse(JSON.stringify(ctx.user.sg_cover || {}));
const lines: Row = all[key] || {};
const cell: Row = { ...(lines[lid] || {}) };
if (value === null) delete cell[field];
else cell[field] = value;
if (Object.keys(cell).length) lines[lid] = cell;
else delete lines[lid];
if (Object.keys(lines).length) all[key] = lines;
else delete all[key];
ctx.setUser({ ...ctx.user, sg_cover: all });
}
function clearLines(ctx: TabContext, key: string, lids: string[]): void {
const all: Row = JSON.parse(JSON.stringify(ctx.user.sg_cover || {}));
for (const lid of lids) delete (all[key] || {})[lid];
if (all[key] && !Object.keys(all[key]).length) delete all[key];
ctx.setUser({ ...ctx.user, sg_cover: all });
}
/** 품 · 단가 고침 표(702 꼴) */
function editTable(ctx: TabContext, block: Row): HTMLElement {
const lines = (block.lines || []) as Row[];
const covered = (i: number, field: string | undefined): boolean =>
!!field && ((lines[i]?.covered || []) as string[]).includes(field);
const rows: DataRow[] = lines.map((ln, n) => ({
key: lineId(n, ln),
name: ln.name || "",
spec: ln.spec || "",
qty: ln.qty,
unit: ln.unit || "",
labor_price: partsOf(ln).includes("labor") ? (ln.price?.labor ?? null) : "",
material_price: partsOf(ln).includes("material") ? (ln.price?.material ?? null) : "",
expense_price: partsOf(ln).includes("expense") ? (ln.price?.expense ?? null) : "",
amount: PARTS.some((p) => (ln[p] ?? null) === null)
? null
: PARTS.reduce((s, p) => s + (ln[p] as number), 0),
base: ln.covered?.length ? baseText(ln) : "",
note: ln.covered?.length ? TAG : ln.why || "",
}));
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", "원래 값", 160),
text("note", "비고", 140),
];
const table = createDataTable({
columns,
rows,
total: { name: "총 계", amount: block.sum },
frozen: 1,
empty: "",
editOnDouble: true,
onCellClick: () => undefined, // 줄 표식(data-i) — 두 번 눌러 고침 · 우클릭 · 풍선 자리
editable: (i, key) => !!FIELD[key] && fields(lines[i] || {}).includes(FIELD[key]),
onEdit: (i, key, value) => {
const ln = lines[i];
const field = FIELD[key];
const v = parseNumber(value);
if (!ln || !field || v === undefined) return;
const was = covered(i, field)
? ln.base?.[field]
: field === "qty"
? ln.qty
: ln.price?.[field];
if (v === null || v === was) {
if (covered(i, field)) setCover(ctx, block.key, lineId(i, ln), field, null);
return;
}
setCover(ctx, block.key, lineId(i, ln), field, v);
},
cellClass: (i, key) => (covered(i, FIELD[key]) ? "is-edited" : null),
});
table.root.dataset.b09SgEdit = block.key;
attachHoverInfo(
table.root,
(cell) => {
const field = FIELD[columns[cellColumn(cell as HTMLTableCellElement)]?.key ?? ""];
const i = Number(cell.parentElement?.dataset.i);
if (!covered(i, field)) return null;
return `${TAG} — ${BACK} ${fmt(lines[i].base?.[field])} · 우클릭 [${BACK}으로]`;
},
"tr[data-i] td",
);
attachRowMenu(table.root, (i): MapContextMenuItem[] =>
lines[i]?.covered?.length
? [[`${BACK}으로`, () => clearLines(ctx, block.key, [lineId(i, lines[i])])]]
: [],
);
return table.root;
}
function blockCard(ctx: TabContext, block: Row, noOf: Record<string, number>): HTMLElement {
let card: HTMLElement | null = null;
const edited = ((block.lines || []) as Row[])
.map((ln, n) => (ln.covered?.length ? lineId(n, ln) : null))
.filter((x): x is string => !!x);
const sheet = el("div", { className: "b09-sg-sheet", attrs: { "data-sg-sheet": block.key } });
const groups = sheetGroups(block, ctx.sheets.machine_blocks || [], noOf);
const rows = groups.length
? calcSheetRows({
묶음: groups,
계: block.sum ?? null,
sumLabel: `계 (원/${block.unit || "식"})`,
bundle: true,
})
: [createText(block.why || "줄 없음", "warning")];
for (const r of rows)
if (r.firstElementChild?.textContent?.includes(TAG)) r.classList.add("is-edited");
sheet.append(...rows);
const heads = ((block.head || []) as string[]).slice(1).map((line) => createText(line, "hint"));
const edit = opened.has(block.key) ? editTable(ctx, block) : null;
heads.forEach((h) => (h.hidden = !opened.has(block.key)));
const toggle = createButton({
label: opened.has(block.key) ? "고치기 닫기" : "품 · 단가 고치기",
variant: "ghost",
size: "sm",
onClick: () => {
if (opened.has(block.key)) opened.delete(block.key);
else opened.add(block.key);
card?.replaceWith(blockCard(ctx, block, noOf)); // 그 카드만 다시(값 · 캐시 그대로)
},
});
const back = edited.length
? [
createButton({
label: `고친 칸 ${edited.length}줄 모두 ${BACK}으로`,
variant: "ghost",
size: "sm",
onClick: () => clearLines(ctx, block.key, edited),
}),
]
: [];
const meta = [
block.spec,
`1 ${block.unit || "식"} 당`,
block.source,
(block.uses || []).length ? `쓰는 곳 ${(block.uses as string[]).join(" · ")}` : "",
edited.length ? `${TAG} ${edited.length}줄` : "",
].filter(Boolean);
card = createCard({
title: `제 ${block.no} 호표 — ${block.name || ""}`,
actions: [...back, toggle],
pad: 12,
body: [
createText(meta.join(" · "), "muted"),
...heads,
sheet,
...(edit
? [
edit,
createText(
`품 · 단가 칸을 두 번 눌러 고침 · 색 칸 = ${TAG}(이 공사만 · [업데이트] 뒤에도 남음) · 우클릭 [${BACK}으로] · [저장] 때 서버가 다시 셈`,
"muted",
),
]
: []),
],
});
card.dataset.sgBlock = block.key;
return card;
}
/** 단가산출근거 탭 판 — focus = 목록표에서 온 산근 키(그 카드로 스크롤) */
export function renderSangeunSheets(ctx: TabContext, focus?: string): HTMLElement[] {
const blocks = (ctx.sheets.sg || []) as Row[];
if (!blocks.length)
return [
createText(((ctx.sheets.sg_notes as string[]) || []).join(" · ") || "산근 없음", "muted", 14),
];
const noOf: Record<string, number> = Object.fromEntries(blocks.map((b) => [b.key, b.no]));
const orphans = (ctx.sheets.sg_orphans || []) as Row[];
const banner = orphans.length
? [
createBanner({
tone: "warning",
text: `짝 잃은 고친 칸 ${orphans.length} — 로직이 바뀌어 줄을 못 찾음(덮개는 남음 · 값은 단가산출 값)`,
}),
]
: [];
const list = el("div", {
className: "b09-sg-sheets",
children: blocks.map((b) => blockCard(ctx, b, noOf)),
});
let placed = false; // 첫 높이 맞춤 뒤 한 번 — 목록표에서 온 블록 · 아니면 보던 자리
fillViewport(list, () => {
if (placed) return;
placed = true;
const target = focus && list.querySelector(`[data-sg-block="${CSS.escape(focus)}"]`);
if (target) target.scrollIntoView({ block: "start" });
else list.scrollTop = lastTop;
});
list.addEventListener("scroll", () => (lastTop = list.scrollTop), { passive: true });
const notes = ((ctx.sheets.sg_notes as string[]) || []).join(" · ");
return [...banner, list, createText(notes, "hint")];
}
@@ -0,0 +1,228 @@
"""719-3 B09 산근 덮개 — 이 공사 단가산출근거 고친 칸(품 · 단가) · 거울(`Sangeun_Cover.py` ↔ `.ts`).
- 고침 없음 = 서버 풀이 그대로(금액 전후 같음)
- 품 · 단가 고침 = 줄 금액 ROUNDDOWN(단가 × 품, 1) · 총계 · 목록표 · 고친 줄 base · covered
- 하위 산근 고침 = 부르는 블록 산근 줄 단가 · 총계로 이어짐 · % 덧줄(인건비의 %) = 밑수 비례
- 줄 이름 다름 · 없는 블록 = 짝 잃음(알림만) · 저장 다듬기에 남음
- 덮개 → 설계내역서 손 줄 단가 · 원가계산서 직접비 · [업데이트](새 단가 판) 뒤 덮개 지킴
"""
import copy
import json
import re
import subprocess
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_Prices as prices # noqa: E402
from B09_Estimation import B09_Estimation_Router as router_mod # noqa: E402
from B09_Estimation import B09_Estimation_Sangeun as sangeun # noqa: E402
from B09_Estimation import B09_Estimation_Store as store # noqa: E402
from B09_Estimation.B09_Estimation_Calc_Base import compute_base, rounddown # noqa: E402
from B09_Estimation.B09_Estimation_Sangeun_Cover import cover_sangeun, line_id # noqa: E402
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
CODES = ["GF000169", "GC001018", "GC000337", "GF000103"] # 하위 산근 · 인건비 % · 중기 · 중기
GIVEN = {"GF000169": {"콘크리트량": 0.2, "거푸집면적": 1.5, "연결철근량": 0.01, "비닐면적": 0.5}}
def _user(cover=None):
rows = [
{"key": f"hand:t{i}", "name": f"시험 {c}", "spec": "", "unit": "식", "qty": 10,
"ref": {"tab": "logic", "code": c, "given": GIVEN.get(c, {})}}
for i, c in enumerate(CODES)
] # fmt: skip
return {"bill": {"v": 2, "rows": rows}, **({"sg_cover": cover} if cover else {})}
def _inputs():
return prices.build_inputs(None, sangeun.hand_resource_keys(_user()))
@pytest.fixture(scope="module")
def sg():
sheets = compute_base(_inputs(), {})
return sangeun.collect([], sheets, sangeun.hand_sources(_user()))
def _block(got, code):
return next(b for b in got["sg"] if b["logic"] == code)
def _at(block, kind):
return next((n, ln) for n, ln in enumerate(block["lines"]) if ln["kind"] == kind)
def _ts(sg, cover):
base = IMPORTS.sub("", (ROOT / "B09_Estimation/B09_Estimation_Calc_Base.ts").read_text("utf-8"))
calc = IMPORTS.sub(
"", (ROOT / "B09_Estimation/B09_Estimation_Sangeun_Cover.ts").read_text("utf-8")
)
body = (
f"\nconst sg = {json.dumps(sg, ensure_ascii=False)};"
f"\nconst c = {json.dumps(cover, ensure_ascii=False)};"
"\nconsole.log(JSON.stringify(coverSangeun(sg, c)));"
)
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-"],
input=base + calc + body, capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def _same(a, b):
if isinstance(a, dict) and isinstance(b, dict):
return a.keys() == b.keys() and all(_same(a[k], b[k]) for k in a)
if isinstance(a, list) and isinstance(b, list):
return len(a) == len(b) and all(_same(x, y) for x, y in zip(a, b))
if isinstance(a, (int, float)) and isinstance(b, (int, float)):
return abs(a - b) < 1e-9
return a == b
def _cover(sg):
"""품 · 단가 · 하위 산근 · 율 · 중기 단가 고침 한 벌 + 짝 잃은 칸 둘."""
sub = _block(sg, "GF000155")
n_l, labor = _at(sub, "labor")
people = _block(sg, "GC001018")
n_p, first = _at(people, "labor")
n_x, _extra = _at(people, "extra")
net = _block(sg, "GC000337")
n_m, machine = _at(net, "machine")
n_t, mat = _at(net, "material")
parent = _block(sg, "GF000169")
n_r, remicon = _at(parent, "material") # 단가 빈칸(대비표에 없음) → 덮개로 채움
return {
parent["key"]: {line_id(n_r, remicon): {"material": 90000}},
sub["key"]: {line_id(n_l, labor): {"qty": labor["qty"] * 2}},
people["key"]: {line_id(n_p, first): {"labor": 300000}, line_id(n_x, _extra): {"qty": 5}},
net["key"]: {
line_id(n_m, machine): {"expense": 20000},
line_id(n_t, mat): {"material": 4500},
},
"SG:없는블록": {"0:x": {"qty": 1}},
net["key"] + "x": {"0:다른이름": {"qty": 1}},
}
def test_고침_없음_그대로(sg):
got = cover_sangeun(sg, {})
assert got["sg"] is sg["sg"] and got["sg_list"] is sg["sg_list"] and got["sg_orphans"] == []
only_orphans = cover_sangeun(sg, {"SG:없는블록": {"0:x": {"qty": 1}}})
assert only_orphans["sg"] == sg["sg"] and only_orphans["sg_orphans"] == [
{"key": "SG:없는블록", "line": "0:x"}
]
def test_품_단가_고침_줄_금액_총계(sg):
before = copy.deepcopy(sg)
got = cover_sangeun(sg, _cover(sg))
assert sg == before # 받은 답 안 고침
net0, net = _block(sg, "GC000337"), _block(got, "GC000337")
n, m = _at(net, "machine")
assert (
m["covered"] == ["expense"]
and m["base"]["expense"] == _at(net0, "machine")[1]["price"]["expense"]
)
assert (
m["expense"] == rounddown(20000 * m["qty"], 1)
and m["labor"] == _at(net0, "machine")[1]["labor"]
)
_n, t = _at(net, "material")
assert t["material"] == rounddown(4500 * t["qty"], 1)
for part in ("labor", "material", "expense"):
assert net[part] == rounddown(sum(ln[part] for ln in net["lines"]), 0)
assert net["sum"] == net["labor"] + net["material"] + net["expense"] != net0["sum"]
row = next(r for r in got["sg_list"] if r["key"] == net["key"])
assert row["sum"] == net["sum"] and row["covered"] == 2
untouched = _block(got, "GF000103")
assert untouched == _block(sg, "GF000103") # 덮개 안 닿은 블록 = 서버 값
def test_하위_산근_이어짐_덧줄_비례(sg):
got = cover_sangeun(sg, _cover(sg))
sub, parent = _block(got, "GF000155"), _block(got, "GF000169")
line = next(ln for ln in parent["lines"] if ln["ref"] and ln["ref"]["key"] == sub["key"])
assert line["price"]["labor"] == sub["labor"] != _block(sg, "GF000155")["labor"]
assert line["labor"] == rounddown(sub["labor"] * line["qty"], 1)
assert parent["labor"] > _block(sg, "GF000169")["labor"]
assert _block(sg, "GF000169")["sum"] is None and parent["sum"] is not None # 빈칸 채움
assert parent["why"] is None and parent["key"] not in {g["key"] for g in got["sg_gaps"]}
people0, people = _block(sg, "GC001018"), _block(got, "GC001018")
n, extra = _at(people, "extra")
assert extra["of"] == "people" and extra["qty"] == 5
base0 = sum(ln["labor"] for ln in people0["lines"][:n] if ln["kind"] == "labor")
base1 = sum(ln["labor"] for ln in people["lines"][:n] if ln["kind"] == "labor")
value0 = _at(people0, "extra")[1]["price"]["expense"]
assert extra["price"]["expense"] == pytest.approx(
value0 * base1 / base0 * 5 / extra["base"]["qty"]
)
def test_짝_잃음(sg):
got = cover_sangeun(sg, _cover(sg))
assert {o["key"] for o in got["sg_orphans"]} == {
"SG:없는블록",
_block(sg, "GC000337")["key"] + "x",
}
def test_거울_파이썬_TS(sg):
cover = _cover(sg)
assert _same(cover_sangeun(sg, cover), _ts(sg, cover))
assert _same(cover_sangeun(sg, {}), _ts(sg, {}))
def test_저장_다듬기_남음(sg):
cover = _cover(sg)
clean = store.clean_user({"sg_cover": cover})["sg_cover"]
assert clean == cover
def test_덮개_대가_원가계산서_업데이트_뒤_지킴(sg, tmp_path):
inputs = _inputs()
plain = router_mod.compute_sheets(tmp_path, copy.deepcopy(inputs), _user())
net = _block(sg, "GC000337")
n, mat = _at(net, "material")
cover = {net["key"]: {line_id(n, mat): {"qty": mat["qty"] + 1}}}
covered = router_mod.compute_sheets(tmp_path, copy.deepcopy(inputs), _user(cover))
gap = rounddown(rounddown(mat["price"]["material"] * (mat["qty"] + 1), 1) - mat["material"], 0)
row0 = next(r for r in plain["sg_list"] if r["key"] == net["key"])
row1 = next(r for r in covered["sg_list"] if r["key"] == net["key"])
assert row1["material"] - row0["material"] in (gap, gap + 1, gap - 1)
hand = next(r for r in covered["bill"]["rows"] if r["key"] == "hand:t2")
hand0 = next(r for r in plain["bill"]["rows"] if r["key"] == "hand:t2")
assert hand["material"] - hand0["material"] == (row1["material"] - row0["material"]) * 10
m0, m1 = plain["total"]["sums"]["material"], covered["total"]["sums"]["material"]
assert m1 - m0 == hand["material"] - hand0["material"]
# [업데이트] = 새 단가 판 — 산근 키 같음 · 덮개 칸 그대로 · 밑 값 = 새 단가
newer = copy.deepcopy(inputs)
for row in newer.get("materials") or []:
if row.get("key") == mat["ref"]["key"]:
for k in ("price", "applied", "lowest"):
if isinstance(row.get(k), (int, float)):
row[k] = row[k] * 2
again = router_mod.compute_sheets(tmp_path, newer, _user(cover))
line = _block(again, "GC000337")["lines"][n]
assert line["covered"] == ["qty"] and line["qty"] == mat["qty"] + 1
assert line["base"]["qty"] == mat["qty"] and again["sg_orphans"] == []
def test_덮인_답_다시_덮음_되돌림(sg):
"""서버 답이 덮인 채 옴([저장] · 셈 답) — 화면 짝이 벗기고 다시 덮음 · 덮개 지우면 처음 값."""
cover = _cover(sg)
once = cover_sangeun(sg, cover)
assert cover_sangeun(once, cover) == once # 두 번 덮어도 같음(원래 값 = 처음 값)
back = cover_sangeun(once, {})
assert back["sg"] == sg["sg"] and back["sg_list"] == sg["sg_list"]
assert back["sg_gaps"] == sg["sg_gaps"]
assert _same(cover_sangeun(once, cover), _ts(once, cover))
assert _same(back, _ts(once, {}))
@@ -0,0 +1,106 @@
"""719-4 B09 단가산출근거 = 공용 계산서 꼴 — 서버 줄 덧칸 · 화면 묶음 옮김(셈 없음) · 금액 같음.
- 서버 산근 줄 `sheet`{쓰는입력 · 기준단위 · 환산근거}(714-1 `master_sheet_line` 한 벌) ·
블록 `기준단위`
- 화면 `sheetGroups` = 노무비 · 재료비 · 경비 묶음(소계 = 블록 비목 총계 · 줄 금액 = 서버 값) →
공용 `bundleLines` = 인력 · 재료 · 경비(장비 한 줄 + 펼침 = 중기사용료 넉 줄) · 하위(산근 n호표)
- 덧칸만 더함 — 줄 값 · 총계 · 목록표는 덧칸 빼면 옛 풀이와 같음(`text` 줄 셈 그대로)
"""
import json
import re
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
for p in (ROOT, ROOT / "resources" / "tester"):
if str(p) not in sys.path:
sys.path.insert(0, str(p))
from test_719_3_sangeun_cover import _inputs, _user # noqa: E402
from B09_Estimation import B09_Estimation_Sangeun as sangeun # noqa: E402
from B09_Estimation.B09_Estimation_Calc_Base import compute_base # noqa: E402
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
@pytest.fixture(scope="module")
def made():
sheets = compute_base(_inputs(), {})
return sheets, sangeun.collect([], sheets, sangeun.hand_sources(_user()))
def _ts(sg, machines):
files = [
"B09_Estimation/B09_Estimation_Calc_Base.ts",
"A00_Common/calc_sheet.ts",
"B09_Estimation/B09_Estimation_UI_Tabs_Sangeun_Sheet.ts",
]
src = "".join(IMPORTS.sub("", (ROOT / f).read_text("utf-8")) for f in files)
body = (
"\nconst currentLanguageIndex = 0;"
f"\nconst sg = {json.dumps(sg, ensure_ascii=False)};"
f"\nconst machines = {json.dumps(machines, ensure_ascii=False)};"
"\nconst noOf = Object.fromEntries(sg.map((b) => [b.key, b.no]));"
"\nconsole.log(JSON.stringify(sg.map((b) => {"
"\n const groups = sheetGroups(b, machines, noOf);"
"\n return { key: b.key, groups, bundles: bundleLines(groups) };"
"\n})));"
)
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-"],
input=src + body, capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
return {x["key"]: x for x in json.loads(result.stdout)}
def test_서버_줄_덧칸(made):
_sheets, sg = made
for b in sg["sg"]:
assert "기준단위" in b
for ln in b["lines"]:
assert set(ln["sheet"]) >= {"쓰는입력", "기준단위", "환산근거"}, ln
assert "_goods" not in ln
net = next(b for b in sg["sg"] if b["logic"] == "GC000337")
assert net["기준단위"] == "㎡"
machine = next(ln for ln in net["lines"] if ln["kind"] == "machine")
assert "크레인" in machine["sheet"]["쓰는입력"]
def test_화면_묶음_옮김_금액_그대로(made):
sheets, sg = made
got = _ts(sg["sg"], sheets["machine_blocks"])
for b in sg["sg"]:
groups = got[b["key"]]["groups"]
cost = {"노무비": "labor", "재료비": "material", "경비": "expense"}
for g in groups:
part = cost[g["비목"]]
assert g["소계"] == b[part]
assert [x["금액"] for x in g["줄"]] == [
ln[part] for ln in b["lines"] if part in _parts(ln)
]
net = next(b for b in sg["sg"] if b["logic"] == "GC000337")
bundles = {x["묶음"]: x for x in got[net["key"]]["bundles"]}
assert set(bundles) == {"인력", "재료", "경비"}
equip = next(x for x in bundles["경비"]["줄"] if "몫" in x)
assert len(equip["몫"]) == 3 # 장비 한 줄 = 노 · 재 · 경
unfold = equip["몫"][0]["줄"]["펼침"]
assert unfold["단위"] == "시간" and len(unfold["줄"]) == 4 # 손료 · 운전사 · 연료 · 잡품
parent = next(b for b in sg["sg"] if b["logic"] == "GF000169")
sub = {x["묶음"]: x for x in got[parent["key"]]["bundles"]}["하위"]
assert all("산근" in x["이름"] and "호표" in x["이름"] for x in sub["줄"])
def _parts(ln):
if ln["kind"] == "labor":
return ["labor"]
if ln["kind"] in ("machine", "sangeun"):
return ["labor", "material", "expense"]
own = [p for p in ln.get("price") or {} if p in ("labor", "material", "expense")]
return own or [p for p in ("labor", "material", "expense") if ln.get(p) is None] or ["material"]