Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1

This commit is contained in:
2026-10-10 00:15:10 +09:00
7 changed files with 207 additions and 35 deletions
+15 -9
View File
@@ -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")
},
}
+22 -13
View File
@@ -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<string, Row>): 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<string, Row>, 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<string, Row>, 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,
};
}
+28 -5
View File
@@ -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<string, TabId> = {
@@ -37,7 +43,11 @@ const LINE_TAB: Record<string, TabId> = {
};
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,
};
};
@@ -4,6 +4,7 @@
*
* 값 = 서버 단독 역전개(`B09_Estimation_Summary` · GET 응답 `summary`) — 조작 중엔 안 바뀜 · [저장] 뒤 정본
* 끝수 = 총수량 ROUND 2 · 금액 ROUND(수량 × 단가, 0) · 줄 누름 = 단가를 낸 목록표 줄
* 경비 집계표 빈 까닭(166-5) = 산근 · 대가 어느 길도 경비 자원 줄을 안 냄(기계 → 중기 · 구역화물 → 자재)
* ========================================================================== */
import type { DataRow } from "@ui/ui_template_data_table";
@@ -26,7 +27,10 @@ const hint = (payload: Row, extra = ""): string => {
return `${HINT}${extra}${n ? ` · 연결 없음 ${n} — 전개 안 함(연결 점검 탭)` : ""}`;
};
function resourceTab(id: string, list: TabId, unitPrice: string): TabBuild {
/** 줄이 없을 때 표 위 맨 앞 안내 */
const EXPENSE_NONE = "경비 갈래 자원 없음 — 중기 · 자재로 셈 · 경비 로직이 들어오면 채워짐";
function resourceTab(id: string, list: TabId, unitPrice: string, none = ""): TabBuild {
return ({ payload }): TabTable => {
const rows: DataRow[] = ((payload.summary || {})[id] || []).map((r: Row) => ({ ...r }));
const sum = rows.reduce((acc, r) => acc + (typeof r.amount === "number" ? r.amount : 0), 0);
@@ -44,7 +48,7 @@ function resourceTab(id: string, list: TabId, unitPrice: string): TabBuild {
rows,
total: rows.length ? { name: "계", amount: sum } : null,
frozen: 2,
hint: hint(payload),
hint: !rows.length && none ? `${none} · ${hint(payload)}` : hint(payload),
link: (i) => (rows[i] ? { tab: list, key: String(rows[i].key) } : null),
};
};
@@ -52,7 +56,7 @@ function resourceTab(id: string, list: TabId, unitPrice: string): TabBuild {
export const buildSumMaterial = resourceTab("sum_material", "material", "단가");
export const buildSumLabor = resourceTab("sum_labor", "labor", "단가");
export const buildSumExpense = resourceTab("sum_expense", "expense", "단가");
export const buildSumExpense = resourceTab("sum_expense", "expense", "단가", EXPENSE_NONE);
export const buildSumMachine: TabBuild = ({ payload }): TabTable => {
const rows: DataRow[] = ((payload.summary || {}).sum_machine || []).map((r: Row) => ({ ...r }));
+13 -4
View File
@@ -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
+25 -1
View File
@@ -8,6 +8,7 @@
"""
import asyncio
import json
import sys
from pathlib import Path
@@ -54,7 +55,7 @@ LINES = {
{"kind": "machine", "key": "X1", "qty": 4.295},
{"kind": "hopyo", "key": "B", "qty": 0.5},
{"kind": "material", "key": "M4", "qty": 0.354},
{"kind": "expense", "key": "E16", "qty": 1.103},
{"kind": "expense", "key": "E16", "qty": 1.103}, # 합성 줄 — 산근 · 대가는 경비 줄을 안 냄(166-5)
]},
"B": {"lines": [{"kind": "labor", "key": "L1", "qty": 1.658},
{"kind": "labor", "key": "L9", "qty": 1}]},
@@ -155,3 +156,26 @@ def test_산근_블록_역전개_엑셀_반올림():
machine = s["sum_machine"][0] # 중기시간금액집계표 10 줄 노무비
assert (machine["qty"], machine["labor"], machine["sum"]) == (1436.14, 79992998, None)
assert s["unlinked"] == 3 # 토공 · 대가 전 구조물 · 수량 빈칸 = 전개 안 함
# ── 166-5 경비 집계표 — 실제 블록 꼴은 경비 자원 0 → 표 위 안내 ──
from test_155_b09_tabs import _node # noqa: E402
EXPENSE_NONE = "경비 갈래 자원 없음 — 중기 · 자재로 셈 · 경비 로직이 들어오면 채워짐"
def _expense_tab(summary):
return _node(
f"\nconst t = buildSumExpense({{ payload: {{ summary: {json.dumps(summary)} }} }});"
"\nconsole.log(JSON.stringify({ n: t.rows.length, hint: t.hint }));",
"UI_Tabs_Summary",
)
def test_166_5_실자료_경비_0_이면_안내():
s = compute_summary(SG_SHEETS, hopyo_lines(SG_SHEETS))
assert s["sum_expense"] == [] # 산근 블록 꼴 = 경비 갈래 줄 없음
out = _expense_tab(s)
assert out["n"] == 0 and out["hint"].startswith(EXPENSE_NONE)
filled = _expense_tab(compute_summary(SHEETS, LINES)) # 합성 경비 줄이 있으면 안내 없음
assert filled["n"] == 1 and EXPENSE_NONE not in filled["hint"]
@@ -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]