diff --git a/B09_Estimation/B09_Estimation_Calc_Labor.py b/B09_Estimation/B09_Estimation_Calc_Labor.py index 69c36ae85..2dbb0035d 100644 --- a/B09_Estimation/B09_Estimation_Calc_Labor.py +++ b/B09_Estimation/B09_Estimation_Calc_Labor.py @@ -14,7 +14,8 @@ 끝수 = 엑셀 자리 그대로 — 줄 금액 = ROUNDDOWN(단가 × 수량, 1) · % 줄 = ROUNDDOWN(밑수 × % / 100, 1) · 관급 · 별도계상 자재 = 대가 안 0원 · 블록 비목 합 = ROUNDDOWN(Σ, 0) · 합계 = 세 비목 합. -빈칸 = 단가 · 수량을 낸 원천이 없으면 None · 그 칸을 쓰는 합도 None · 「연결 없음」 목록(gaps). +빈칸 = 단가 · 수량을 낸 원천이 없으면 None · 「연결 없음」 목록(gaps). +빈 줄(166-6) = 블록 합 = 아는 줄 합 + `blank` n · `blank_names` — 내역 단가 · 부른 대가로는 안 넘김. """ from __future__ import annotations @@ -49,7 +50,7 @@ def _prices(line: dict[str, Any], books: dict[str, Any], sums: dict) -> dict[str key ) or {} for part in PARTS: - out[part] = num(row.get(part)) + out[part] = None if row.get("blank") else num(row.get(part)) # 빈 줄 대가 = 빈칸 else: # blank — 못 펼친 줄 out = dict.fromkeys(PARTS, None) return out @@ -109,17 +110,18 @@ def _block( for line in lines: line["price"] = total(list(line["prices"].values())) line["amount"] = total(list(line["amounts"].values())) - parts: dict[str, float | None] = {} - for part in PARTS: - s = total([ln["amounts"][part] for ln in lines]) - parts[part] = None if s is None else rounddown(s, 0) + known = [ln for ln in lines if ln["amount"] is not None] + parts = {part: rounddown(sum(ln["amounts"][part] for ln in known), 0) for part in PARTS} + blank_names = [ln.get("name") or "" for ln in lines if ln["amount"] is None] return { **{k: block.get(k) for k in ("id", "kind", "name", "spec", "unit", "cite")}, **({"spec_raw": block["spec_raw"]} if block.get("spec_raw") else {}), # 157-4 풍선 "key": block["id"], "lines": lines, **parts, - "sum": total([parts[p] for p in PARTS]), + "sum": sum(parts.values()), + "blank": len(blank_names), + "blank_names": blank_names, } @@ -178,6 +180,8 @@ def compute_labor( "labor": b["labor"], "material": b["material"], "expense": b["expense"], + "blank": b["blank"], + "blank_names": b["blank_names"], } for b in out_blocks ] @@ -192,7 +196,7 @@ def compute_labor( def daega_price(sheets: dict[str, Any], owner: str) -> dict[str, Any] | None: """내역서(145-3)가 읽는 대가 단가 — 구조물 id(연결된 사본 주인) → 호표 · 비목 단가. - 사본이 없으면 None.""" + 사본이 없으면 None · 빈 줄 있는 블록 = 비목 단가 빈칸(166-6 · 아는 줄 합은 안 넘김).""" bid = (sheets.get("daega_owners") or {}).get(owner) row = next((r for r in sheets.get("daega") or [] if r["key"] == bid), None) if row is None: @@ -200,5 +204,7 @@ def daega_price(sheets: dict[str, Any], owner: str) -> dict[str, Any] | None: return { "no": f"대가{row['no']}", "key": bid, - **{k: row[k] for k in ("labor", "material", "expense", "sum")}, + **{ + k: None if row.get("blank") else row[k] for k in ("labor", "material", "expense", "sum") + }, } diff --git a/B09_Estimation/B09_Estimation_Calc_Labor.ts b/B09_Estimation/B09_Estimation_Calc_Labor.ts index 2baf3f019..312e27582 100644 --- a/B09_Estimation/B09_Estimation_Calc_Labor.ts +++ b/B09_Estimation/B09_Estimation_Calc_Labor.ts @@ -11,7 +11,8 @@ * 비고 = 대가 줄 「대가n」 · 산근 줄 「산근 n호표」(산근 목록 번호) * 줄 금액 ROUNDDOWN(단가 × 수량, 1) · 블록 비목 합 ROUNDDOWN(Σ, 0) · 합계 = 세 비목 합 * 관급 · 별도계상 자재 = 대가 안 0원(자재대 · 관급 단락에서 셈) - * 빈칸 = 원천이 없으면 null · 그 칸을 쓰는 합도 null · 「연결 없음」 목록(gaps) + * 빈칸 = 원천이 없으면 null · 「연결 없음」 목록(gaps) + * 빈 줄(166-6) = 블록 합 = 아는 줄 합 + blank n · blank_names — 내역 단가 · 부른 대가로는 안 넘김 * ========================================================================== */ import { @@ -51,7 +52,7 @@ function linePrices(line: Row, books: Row, sums: Record): Parts { } else if (kind === "machine" || kind === "daega" || kind === "sangeun") { const src: Row = ({ machine: books.machine, daega: sums, sangeun: books.sangeun } as Row)[kind]; const row: Row = src[key] || {}; - for (const part of PARTS) out[part] = num(row[part]); + for (const part of PARTS) out[part] = row.blank ? null : num(row[part]); // 빈 줄 대가 = 빈칸 } else return blanks(); // blank — 못 펼친 줄 return out; } @@ -104,11 +105,14 @@ function block(b: Row, books: Row, sums: Record, gaps: Gap[]): Row line.price = total(PARTS.map((p) => line.prices[p])); line.amount = total(PARTS.map((p) => line.amounts[p])); } - const parts = blanks(); - for (const part of PARTS) { - const s = total(lines.map((ln) => ln.amounts[part])); - parts[part] = s === null ? null : rounddown(s, 0); - } + const known = lines.filter((ln) => ln.amount !== null); + const parts = zeros(); + for (const part of PARTS) + parts[part] = rounddown( + known.reduce((acc, ln) => acc + ln.amounts[part], 0), + 0, + ); + const blankNames = lines.filter((ln) => ln.amount === null).map((ln) => ln.name || ""); return { id: b.id, key: b.id, @@ -120,7 +124,9 @@ function block(b: Row, books: Row, sums: Record, gaps: Gap[]): Row cite: b.cite, lines, ...parts, - sum: total(PARTS.map((p) => parts[p])), + sum: PARTS.reduce((acc, p) => acc + (parts[p] as number), 0), + blank: blankNames.length, + blank_names: blankNames, }; } @@ -167,6 +173,8 @@ export function computeLabor(daega: Row | null | undefined, sheets: Row, sangeun labor: b.labor, material: b.material, expense: b.expense, + blank: b.blank, + blank_names: b.blank_names, })); return { daega_blocks: outBlocks, @@ -176,7 +184,8 @@ export function computeLabor(daega: Row | null | undefined, sheets: Row, sangeun }; } -/** 내역서(145-3)가 읽는 대가 단가 — 구조물 id → 호표 · 비목 단가 · 없으면 null */ +/** 내역서(145-3)가 읽는 대가 단가 — 구조물 id → 호표 · 비목 단가 · 없으면 null · + * 빈 줄 있는 블록 = 비목 단가 빈칸(166-6 · 아는 줄 합은 안 넘김) */ export function daegaPrice(sheets: Row, owner: string): Row | null { const bid = (sheets.daega_owners || {})[owner]; const row = (sheets.daega || []).find((r: Row) => r.key === bid); @@ -184,9 +193,9 @@ export function daegaPrice(sheets: Row, owner: string): Row | null { return { no: `대가${row.no}`, key: bid, - labor: row.labor, - material: row.material, - expense: row.expense, - sum: row.sum, + labor: row.blank ? null : row.labor, + material: row.blank ? null : row.material, + expense: row.blank ? null : row.expense, + sum: row.blank ? null : row.sum, }; } diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts index cb15fedec..0330e005d 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts @@ -6,6 +6,7 @@ * 일위대가표 = 블록(머리 · [품셈 인용] · 줄 · 합계) · 줄 누름 = 단가를 낸 탭 · 줄(대가 줄 = 그 블록) * 줄 꼴(155-2) = 머리 · 합계 굵게(`is-title`) · 인용 · 줄 = 들여쓰기 「ㄴ」(2단 · 겹 대가는 안 펼침) * 연결된 구조물 조합 사본이 없으면 빈 표 「연결 없음」 + * 빈 줄(166-6) = 합계 = 아는 줄 합 · 옆 「빈 줄 n」 · 풍선 = 빈 줄 이름 · 내역 단가엔 안 넘김 * ========================================================================== */ import type { DataRow } from "@ui/ui_template_data_table"; @@ -26,6 +27,11 @@ const EMPTY = /** 하위 줄 앞 — 들여쓰기 + 「ㄴ」(공용 표가 앞 빈칸을 접지 않음) */ const CHILD = " ㄴ "; const ROUNDS = "줄 금액 ROUNDDOWN(단가 × 수량, 1) · % 줄 = 밑수 × % / 100 · 합계 ROUNDDOWN 0"; +const BLANK = "빈 줄 n = 금액 모르는 줄 · 합계 = 아는 줄 합 · 내역 단가엔 안 넘김"; +/** 「빈 줄 n」 작은 글 · 풍선 = 빈 줄 이름 */ +const blankNote = (b: Row): string => (b.blank ? `빈 줄 ${b.blank}` : ""); +const blankTip = (b: Row | undefined): string | null => + b?.blank ? `빈 줄 — ${(b.blank_names as string[]).join(" · ")}` : null; /** 블록 줄 → 단가를 낸 탭 */ const LINE_TAB: Record = { @@ -37,7 +43,11 @@ const LINE_TAB: Record = { }; export const buildDaegaList: TabBuild = ({ sheets }): TabTable | string => { - const rows: DataRow[] = (sheets.daega || []).map((r: Row) => ({ ...r, no: `대가${r.no}` })); + const rows: DataRow[] = (sheets.daega || []).map((r: Row) => ({ + ...r, + no: `대가${r.no}`, + blank_note: blankNote(r), + })); if (!rows.length) return EMPTY; return { columns: [ @@ -47,15 +57,21 @@ export const buildDaegaList: TabBuild = ({ sheets }): TabTable | string => { text("spec", "규격", 200), text("unit", "단위", 56), money("sum", "합계"), + text("blank_note", "", 64), money("labor", "노무비"), money("material", "재료비"), money("expense", "경비"), ], rows, frozen: 3, - hint: "호표 = 일위대가표 블록 합계 · 줄을 누르면 그 블록으로", + hint: `호표 = 일위대가표 블록 합계 · ${BLANK} · 줄을 누르면 그 블록으로`, link: (i: number): Link | null => (rows[i] ? { tab: "dg", key: String(rows[i].key) } : null), - tip: (i, key) => (key === "spec" ? raw(rows[i]?.spec, rows[i]?.spec_raw) : null), + tip: (i, key) => + key === "spec" + ? raw(rows[i]?.spec, rows[i]?.spec_raw) + : key === "blank_note" || key === "sum" + ? blankTip(rows[i]) + : null, }; }; @@ -109,6 +125,9 @@ export const buildDaegaBlocks: TabBuild = ({ sheets }): TabTable | string => { labor: b.labor, material: b.material, expense: b.expense, + note: blankNote(b), + blank: b.blank, + blank_names: b.blank_names, }); links.push(null); } @@ -130,10 +149,14 @@ export const buildDaegaBlocks: TabBuild = ({ sheets }): TabTable | string => { ], rows, frozen: 1, - hint: `단가 = B09 프로젝트 한 벌 굳힌 단가 · ${ROUNDS} · 줄 누름 = 단가를 낸 탭(대가 줄 = 그 블록)`, + hint: `단가 = B09 프로젝트 한 벌 굳힌 단가 · ${ROUNDS} · ${BLANK} · 줄 누름 = 단가를 낸 탭(대가 줄 = 그 블록)`, link: (i: number) => links[i] ?? null, rowClass: (i: number) => (titles.has(i) ? "is-title" : null), tip: (i: number, key: string) => - key === "spec" ? raw(rows[i]?.spec, rows[i]?.spec_raw) : null, + key === "spec" + ? raw(rows[i]?.spec, rows[i]?.spec_raw) + : key === "note" || key === "sum" + ? blankTip(rows[i]) + : null, }; }; diff --git a/resources/tester/test_145_2_b09_labor_calc.py b/resources/tester/test_145_2_b09_labor_calc.py index 10c566831..53ba3e03e 100644 --- a/resources/tester/test_145_2_b09_labor_calc.py +++ b/resources/tester/test_145_2_b09_labor_calc.py @@ -124,6 +124,7 @@ def test_엑셀_대가_블록_검산(): assert out["daega"][0] == { "no": 1, "key": "DG14", "kind": "조합", "name": "돌붙임(채집)", "spec": "", "unit": "", "sum": 101522, "labor": 77755, "material": 9519, "expense": 14248, + "blank": 0, "blank_names": [], } # fmt: skip assert daega_price(out, "pipe@100.0") == { "no": "대가1", @@ -147,11 +148,19 @@ def _blank_cases(): def test_빈칸_규칙(): + """166-6 — 블록 합 = 아는 줄 합 + 빈 줄 n · 내역 단가는 빈칸.""" out = compute_labor(_blank_cases(), SHEETS, {}) - assert _block(out, "DG2")["sum"] is None and _block(out, "DG2")["labor"] is None - assert _block(out, "DG14")["sum"] is None # 산근 단가 없음(145-1 전) - assert _block(out, "DGZ")["sum"] is None - assert _block(out, "DGX")["sum"] is None or _block(out, "DGY")["sum"] is None # 순환 = 빈칸 + dg2 = _block(out, "DG2") + assert (dg2["sum"], dg2["labor"], dg2["blank"]) == (6013, 5275, 2) + assert dg2["blank_names"] == ["L00032", "공구손료"] # 노임 없음 · 그 노무 밑수 % 줄 + dg14 = _block(out, "DG14") # 산근 단가 없음(145-1 전) + assert (dg14["sum"], dg14["blank"]) == (53926, 3) + assert _sums(dg14) == (53926, 43539, 3842, 6545) + assert (_block(out, "DGZ")["sum"], _block(out, "DGZ")["blank"]) == (0, 1) + assert _block(out, "DGX")["blank"] + _block(out, "DGY")["blank"] >= 1 # 순환 = 빈 줄 + assert daega_price(out, "pipe@100.0") == { + "no": "대가1", "key": "DG14", "labor": None, "material": None, "expense": None, "sum": None, + } # fmt: skip whys = {g["why"] for g in out["gaps"]} assert {"노임 단가 없음", "산근 단가 없음(145-1)", "수량 없음", "% 밑수 없음"} <= whys diff --git a/resources/tester/test_166_6_b09_daega_blank.py b/resources/tester/test_166_6_b09_daega_blank.py new file mode 100644 index 000000000..bb79f5925 --- /dev/null +++ b/resources/tester/test_166_6_b09_daega_blank.py @@ -0,0 +1,97 @@ +"""166-6 일위대가목록표 블록 합 — 빈 줄 있는 블록 = 아는 줄 합 + 「빈 줄 n」 · 내역 단가엔 안 넘김. + +견본 = 대가 A(노무 아는 줄 · 자재 단가 없음 줄) · 대가 B(A 를 부름 → 그 줄도 빈 줄) · 대가 C(빈 줄 없음). +파이썬 `B09_Estimation_Calc_Labor` ↔ TS 짝 거울 + 목록표 화면 「빈 줄 n」 · 풍선(Node 타입 지움). +""" + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor, daega_price # noqa: E402 +from test_145_2_b09_labor_calc import _same, _ts # noqa: E402 +from test_155_b09_tabs import _node # noqa: E402 + +SHEETS = { + "labor": [{"key": "L1", "price": 165545}], + "material": [{"key": "M1", "price": None}], + "machine": [], + "gaps": [], +} + + +def _b(bid, name, lines): + return { + "id": bid, + "kind": "조합", + "name": name, + "spec": "", + "unit": "m", + "cite": "", + "lines": lines, + } + + +DAEGA = { + "blocks": [ + _b("A", "기슭막이", [ + {"kind": "labor", "key": "L1", "qty": 0.5, "name": "보통인부"}, + {"kind": "material", "key": "M1", "qty": 2, "name": "토목용부직포"}, + ]), + _b("B", "겹 대가", [ + {"kind": "labor", "key": "L1", "qty": 0.1, "name": "보통인부"}, + {"kind": "daega", "key": "A", "qty": 1, "name": "기슭막이"}, + ]), + _b("C", "아는 줄만", [{"kind": "labor", "key": "L1", "qty": 1, "name": "보통인부"}]), + ], + "owners": {"pipe@100.0": "A", "pipe@200.0": "C"}, +} # fmt: skip + + +def test_아는_줄_합_빈_줄_n(): + out = compute_labor(DAEGA, SHEETS) + rows = {r["key"]: r for r in out["daega"]} + # 165,545 × 0.5 = 82,772.5 → 블록 ROUNDDOWN 0 · 자재 줄은 빈 줄 + assert (rows["A"]["sum"], rows["A"]["labor"], rows["A"]["material"]) == (82772, 82772, 0) + assert (rows["A"]["blank"], rows["A"]["blank_names"]) == (1, ["토목용부직포"]) + # 부른 대가에 빈 줄 → 그 줄도 빈 줄(아는 줄 합을 위로 안 넘김) + assert (rows["B"]["sum"], rows["B"]["blank"], rows["B"]["blank_names"]) == ( + 16554, + 1, + ["기슭막이"], + ) + assert (rows["C"]["sum"], rows["C"]["blank"]) == (165545, 0) + # 목록표 = 블록 합계 그대로(같은 셈 한 벌) + for b in out["daega_blocks"]: + assert _same( + {k: rows[b["key"]][k] for k in ("sum", "blank")}, {"sum": b["sum"], "blank": b["blank"]} + ) + # 내역 단가 = 빈 줄 블록은 빈칸 · 없는 블록은 그대로 + assert daega_price(out, "pipe@100.0")["sum"] is None + assert daega_price(out, "pipe@100.0")["labor"] is None + assert daega_price(out, "pipe@200.0")["sum"] == 165545 + + +def test_거울_파이썬_TS(): + py = json.loads(json.dumps(compute_labor(DAEGA, SHEETS), ensure_ascii=False)) + ts = _ts(DAEGA, SHEETS, {}) + assert _same(py, ts["out"]) + assert _same(daega_price(py, "pipe@100.0"), ts["price"]) + + +def test_목록표_빈_줄_글_풍선(): + sheets = json.dumps(compute_labor(DAEGA, SHEETS), ensure_ascii=False) + out = _node( + f"\nconst t = buildDaegaList({{ sheets: {sheets} }});" + "\nconsole.log(JSON.stringify({ notes: t.rows.map((r) => r.blank_note)," + " sums: t.rows.map((r) => r.sum), cols: t.columns.map((c) => c.key)," + " tips: t.rows.map((_, i) => t.tip(i, 'blank_note') ?? null) }));" + ) + assert out["cols"][5:7] == ["sum", "blank_note"] # 합계 바로 옆 + assert out["notes"] == ["빈 줄 1", "빈 줄 1", ""] + assert out["sums"] == [82772, 16554, 165545] + assert out["tips"] == ["빈 줄 — 토목용부직포", "빈 줄 — 기슭막이", None]