diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 7d801ed3b..f1da8d317 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -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} 그대로", diff --git a/B09_Estimation/B09_Estimation_Sangeun.py b/B09_Estimation/B09_Estimation_Sangeun.py index dedd80e90..0e90f6548 100644 --- a/B09_Estimation/B09_Estimation_Sangeun.py +++ b/B09_Estimation/B09_Estimation_Sangeun.py @@ -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") diff --git a/B09_Estimation/B09_Estimation_Sangeun_Cover.py b/B09_Estimation/B09_Estimation_Sangeun_Cover.py new file mode 100644 index 000000000..9d19988c9 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Sangeun_Cover.py @@ -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} diff --git a/B09_Estimation/B09_Estimation_Sangeun_Cover.ts b/B09_Estimation/B09_Estimation_Sangeun_Cover.ts new file mode 100644 index 000000000..91792efaa --- /dev/null +++ b/B09_Estimation/B09_Estimation_Sangeun_Cover.ts @@ -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): 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, 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(); + 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 }; +} diff --git a/B09_Estimation/B09_Estimation_Store.py b/B09_Estimation/B09_Estimation_Store.py index 88f1a2b34..bd1402293 100644 --- a/B09_Estimation/B09_Estimation_Store.py +++ b/B09_Estimation/B09_Estimation_Store.py @@ -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 diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index c348665ae..d008489e1 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -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)); +} diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 62d8dcfb6..5e6334b8f 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -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)); diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts index 8eb60ecdf..cf67ae01b 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun.ts @@ -8,6 +8,8 @@ * 노무비 · 재료비 · 경비 — 머리(제 n 호표 · 출처 · 입력 · 중간 Q) → 줄(중기 = 노 · 재 · 경 * 세 줄) → 총계(계) · 줄 누름 = 단가를 낸 탭 · 줄(노무비목록표 · 중기목록표 · 자재단가대비표 · * 하위 산근) + * 719-4 — 단가산출근거 탭 본 화면 = 공용 계산서 카드(`…_UI_Tabs_Sangeun_Sheet`) · 풀이표는 곁 창(행 누름 + * 팝업)에만 씀 * ========================================================================== */ import type { DataRow } from "@ui/ui_template_data_table"; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun_Sheet.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun_Sheet.ts new file mode 100644 index 000000000..818243b7c --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Sangeun_Sheet.ts @@ -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 = { labor: "노무비", material: "재료비", expense: "경비" }; +export const TAG = "이 공사에서 고침"; +const BACK = "단가산출 값"; +const LABEL: Record = { + qty: "품", + labor: "노무비", + material: "재료비", + expense: "경비", +}; +const FIELD: Record = { + 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, +): 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(); // 고침 표 · 입력 펼친 블록(다시 그려도 그대로) +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): 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 = 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")]; +} diff --git a/resources/tester/test_719_3_sangeun_cover.py b/resources/tester/test_719_3_sangeun_cover.py new file mode 100644 index 000000000..f233b0507 --- /dev/null +++ b/resources/tester/test_719_3_sangeun_cover.py @@ -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, {})) diff --git a/resources/tester/test_719_4_sangeun_sheet.py b/resources/tester/test_719_4_sangeun_sheet.py new file mode 100644 index 000000000..6308c46fd --- /dev/null +++ b/resources/tester/test_719_4_sangeun_sheet.py @@ -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"]