feat(B09): 집계표 역전개를 산근 블록에 이음 · 연결 없음 n(145-5)
- hopyo_lines(sheets) = 산근 블록(145-1 sheets['sg']) · 대가 블록(145-2 daega_blocks) 줄 → 호표 구성 - 산근 → 하위 산근 → 중기 겹 전개 · % 줄 · 빈칸 줄은 자원 아님 - 수량 · 호표 · 호표 구성 없는 내역 항목 = 전개 안 함 · summary.unlinked · 화면 안내 「연결 없음 n」 - 시험 = 엑셀 노무 석공 · 보통인부 · 재료 폭약 · 중기 굴삭기+브레카 반올림 줄 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL
This commit is contained in:
@@ -81,7 +81,7 @@ async def payload(project_id: UUID, root: Path) -> dict[str, Any]:
|
||||
"rate_candidates": rate_candidates(),
|
||||
"notes": notes,
|
||||
# 집계표 넷 = 서버 단독(145-5) — 짝 셈 결과를 역전개 · 조작 중엔 안 바뀜
|
||||
"summary": compute_summary(sheets, hopyo_lines(root)),
|
||||
"summary": compute_summary(sheets, hopyo_lines(sheets)),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,9 +2,13 @@
|
||||
(서버 단독).
|
||||
|
||||
역전개 = 설계내역서 항목 수량 × 호표 구성(산근 · 대가 줄 · 겹 호표 끝까지) → 자원마다 총수량.
|
||||
호표 구성 = `hopyo_lines` — 145-1 · 2(산근 · 대가)가 `{호표 키: {"lines": [{kind, key, qty}]}}`
|
||||
(호표 1 단위당 · kind = labor · material · expense · machine · hopyo)로 줌 · 그전엔 빈 묶음
|
||||
호표 구성 = `hopyo_lines(sheets)` — 산근 블록(145-1 `sheets['sg']`) · 대가 블록
|
||||
(145-2 `sheets['daega_blocks']`) 줄을 `{호표 키: {"lines": [{kind, key, qty}]}}`
|
||||
(호표 1 단위당 · kind = labor · material · expense · machine · hopyo)로 바꿈 ·
|
||||
중기 = 중기목록표 시간당 노 · 재 · 경(144-4 블록) 그대로 · % 줄 · 빈칸 줄 = 자원 아님(안 셈)
|
||||
4 자재대 · 관급자재대 자재 줄 = 그 자재 그대로(호표 없음)
|
||||
연결 없는 항목(수량 · 호표 없음 · 호표 구성 없음 — 조합 밖 토공 · 운반 산근 등) = 전개 안 함 ·
|
||||
「연결 없음 n」(`unlinked`)
|
||||
끝수 = 엑셀 자리 그대로 — 총수량 ROUND 2 · 금액 = ROUND(수량 × 단가, 0)(여기만 반올림) ·
|
||||
중기 = 노무 · 재료 · 경비 각 ROUND(시간 × 시간당, 0) · 합계 = 셋 합.
|
||||
단가 = 목록표(노무비 · 재료비 · 경비 · 중기목록표) — 없으면 금액 빈칸(수량은 둠).
|
||||
@@ -40,20 +44,26 @@ def expand(
|
||||
bucket[str(line.get("key"))] = bucket.get(str(line.get("key")), 0.0) + qty * q
|
||||
|
||||
|
||||
def totals(sheets: dict[str, Any], lines_of: dict[str, Any]) -> dict[str, dict[str, float]]:
|
||||
"""설계내역서 항목 → 자원 총수량(내역 차례 · 관급 포함)."""
|
||||
def totals(
|
||||
sheets: dict[str, Any], lines_of: dict[str, Any]
|
||||
) -> tuple[dict[str, dict[str, float]], int]:
|
||||
"""설계내역서 항목 → (자원 총수량(내역 차례 · 관급 포함), 연결 없음 항목 수)."""
|
||||
out: dict[str, dict[str, float]] = {}
|
||||
unlinked = 0
|
||||
for row in (sheets.get("bill") or {}).get("rows") or []:
|
||||
if row.get("kind") != "item":
|
||||
continue
|
||||
qty = num(row.get("qty"))
|
||||
ref = row.get("ref") or {}
|
||||
if row.get("kind") != "item" or qty is None or not ref.get("key"):
|
||||
continue
|
||||
if ref.get("tab") == "material":
|
||||
key = str(ref.get("key") or "")
|
||||
if ref.get("tab") == "material" and key and qty is not None:
|
||||
bucket = out.setdefault("material", {})
|
||||
bucket[ref["key"]] = bucket.get(ref["key"], 0.0) + qty
|
||||
bucket[key] = bucket.get(key, 0.0) + qty
|
||||
elif qty is not None and key in lines_of:
|
||||
expand(lines_of, key, qty, out)
|
||||
else:
|
||||
expand(lines_of, str(ref["key"]), qty, out)
|
||||
return out
|
||||
unlinked += 1
|
||||
return out, unlinked
|
||||
|
||||
|
||||
def _list(rows: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
||||
@@ -110,8 +120,9 @@ def _machine_sheet(used: dict[str, float], listed: dict[str, dict[str, Any]]):
|
||||
|
||||
def compute_summary(sheets: dict[str, Any], lines_of: dict[str, Any]) -> dict[str, Any]:
|
||||
"""집계표 넷 — `sheets` = 짝 셈 결과(바탕 + 설계내역서)."""
|
||||
used = totals(sheets, lines_of)
|
||||
used, unlinked = totals(sheets, lines_of)
|
||||
return {
|
||||
"unlinked": unlinked,
|
||||
"sum_material": _resource_sheet(used.get("material", {}), _list(sheets["material"]), ""),
|
||||
"sum_labor": _resource_sheet(used.get("labor", {}), _list(sheets["labor"]), "인"),
|
||||
"sum_expense": _resource_sheet(used.get("expense", {}), _list(sheets["expense"]), ""),
|
||||
@@ -119,6 +130,31 @@ def compute_summary(sheets: dict[str, Any], lines_of: dict[str, Any]) -> dict[st
|
||||
}
|
||||
|
||||
|
||||
def hopyo_lines(project_root: Any) -> dict[str, Any]:
|
||||
"""호표 구성(1 단위당 자원 · 겹 호표) — 145-1 · 2 가 들어오면 채움 · 그전엔 빈 묶음."""
|
||||
return {}
|
||||
#: 산근 · 대가 블록 줄 kind → 호표 구성 kind(자원 · 겹 호표) — % 줄 · 빈칸 줄은 빠짐
|
||||
LINE_KIND = {
|
||||
"labor": "labor",
|
||||
"material": "material",
|
||||
"expense": "expense",
|
||||
"machine": "machine",
|
||||
"sangeun": "hopyo",
|
||||
"daega": "hopyo",
|
||||
}
|
||||
|
||||
|
||||
def block_lines(blocks: list[dict[str, Any]] | None) -> dict[str, Any]:
|
||||
"""블록 [{key, lines: [{kind, ref: {key}, qty}]}] → {호표 키: {"lines": [{kind, key, qty}]}}."""
|
||||
out: dict[str, Any] = {}
|
||||
for block in blocks or []:
|
||||
lines = []
|
||||
for line in block.get("lines") or []:
|
||||
kind = LINE_KIND.get(line.get("kind"))
|
||||
key = (line.get("ref") or {}).get("key")
|
||||
if kind and key and num(line.get("qty")) is not None:
|
||||
lines.append({"kind": kind, "key": str(key), "qty": line["qty"]})
|
||||
out[str(block["key"])] = {"lines": lines}
|
||||
return out
|
||||
|
||||
|
||||
def hopyo_lines(sheets: dict[str, Any]) -> dict[str, Any]:
|
||||
"""호표 구성(1 단위당 자원 · 겹 호표) — 산근(145-1) + 대가(145-2 · 들어오면)."""
|
||||
return {**block_lines(sheets.get("sg")), **block_lines(sheets.get("daega_blocks"))}
|
||||
|
||||
@@ -20,6 +20,12 @@ import {
|
||||
const HINT =
|
||||
"총수량 = 설계내역서 수량 × 호표(산근 · 대가) 구성 역전개(서버 · [저장] 뒤 다시 셈) · ROUND 2 · 금액 = ROUND(수량 × 단가, 0) · 줄 누름 = 목록표";
|
||||
|
||||
/** 안내 + 「연결 없음 n」(전개 안 한 내역 항목 — 조합 밖 토공 · 운반 산근 등) */
|
||||
const hint = (payload: Row, extra = ""): string => {
|
||||
const n = (payload.summary || {}).unlinked || 0;
|
||||
return `${HINT}${extra}${n ? ` · 연결 없음 ${n} — 전개 안 함(연결 점검 탭)` : ""}`;
|
||||
};
|
||||
|
||||
function resourceTab(id: string, list: TabId, unitPrice: string): TabBuild {
|
||||
return ({ payload }): TabTable => {
|
||||
const rows: DataRow[] = ((payload.summary || {})[id] || []).map((r: Row) => ({ ...r }));
|
||||
@@ -38,7 +44,7 @@ function resourceTab(id: string, list: TabId, unitPrice: string): TabBuild {
|
||||
rows,
|
||||
total: rows.length ? { name: "계", amount: sum } : null,
|
||||
frozen: 2,
|
||||
hint: HINT,
|
||||
hint: hint(payload),
|
||||
link: (i) => (rows[i] ? { tab: list, key: String(rows[i].key) } : null),
|
||||
};
|
||||
};
|
||||
@@ -76,7 +82,7 @@ export const buildSumMachine: TabBuild = ({ payload }): TabTable => {
|
||||
}
|
||||
: null,
|
||||
frozen: 2,
|
||||
hint: `${HINT} · 비목마다 ROUND(시간 × 시간당, 0)`,
|
||||
hint: hint(payload, " · 비목마다 ROUND(시간 × 시간당, 0)"),
|
||||
link: (i) => (rows[i] ? { tab: "machine", key: String(rows[i].key) } : null),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -93,5 +93,65 @@ def test_GET_응답에_집계표(tmp_path, monkeypatch):
|
||||
|
||||
monkeypatch.setattr(router_mod, "material_sheets", fake_materials)
|
||||
body = asyncio.run(router_mod.payload("p", tmp_path))
|
||||
assert set(body["summary"]) == {"sum_material", "sum_labor", "sum_expense", "sum_machine"}
|
||||
assert set(body["summary"]) == {
|
||||
"sum_material", "sum_labor", "sum_expense", "sum_machine", "unlinked",
|
||||
} # fmt: skip
|
||||
assert body["summary"]["sum_material"] == [] # 「별도」 · 「관급」 표시 전엔 내역 자재 줄 없음
|
||||
|
||||
|
||||
# ── 산근 블록(145-1 `sheets['sg']` 꼴)으로 역전개 — 산근 → 하위 산근 → 중기 · 엑셀 반올림 줄 ──
|
||||
from B09_Estimation.B09_Estimation_Summary import hopyo_lines # noqa: E402
|
||||
|
||||
|
||||
def _ln(kind, tab, key, qty):
|
||||
return {"kind": kind, "ref": {"tab": tab, "key": key}, "qty": qty, "name": key}
|
||||
|
||||
|
||||
SG = [
|
||||
{"key": "S1", "lines": [
|
||||
_ln("labor", "labor", "L3", 1.3701),
|
||||
_ln("machine", "machine", "X7", 14.3614),
|
||||
_ln("sangeun", "sg", "S2", 0.5),
|
||||
{"kind": "extra", "ref": None, "qty": 1.0, "name": "잡재료 2%"}, # % 줄 = 자원 아님
|
||||
{"kind": "blank", "ref": None, "qty": None, "name": "빈칸"},
|
||||
]},
|
||||
{"key": "S2", "lines": [
|
||||
_ln("labor", "labor", "L6", 4.4008),
|
||||
_ln("material", "compare", "M7", 80.5194),
|
||||
]},
|
||||
] # fmt: skip
|
||||
SG_SHEETS = {
|
||||
"labor": [
|
||||
{"key": "L3", "name": "석공", "price": 258935},
|
||||
{"key": "L6", "name": "보통인부", "price": 165545},
|
||||
],
|
||||
"material": [{"key": "M7", "name": "폭약", "spec": "28m/m", "unit": "KG", "price": 12712}],
|
||||
"expense": [],
|
||||
"machine": [{"key": "X7", "name": "굴삭기+브레카(0.7m3)", "labor": 55700, "material": None,
|
||||
"expense": None}],
|
||||
"sg": SG,
|
||||
"bill": {"rows": [
|
||||
{"kind": "item", "key": "rock", "qty": 100, "ref": {"tab": "sg_list", "key": "S1"}},
|
||||
{"kind": "item", "key": "earth", "qty": 5, "ref": None}, # 조합 밖 토공
|
||||
{"kind": "item", "key": "st", "qty": 2, "ref": {"tab": "dg_list", "key": "st:x"}},
|
||||
{"kind": "item", "key": "none", "qty": None, "ref": {"tab": "sg_list", "key": "S1"}},
|
||||
]},
|
||||
} # fmt: skip
|
||||
|
||||
|
||||
def test_산근_블록_역전개_엑셀_반올림():
|
||||
lines = hopyo_lines(SG_SHEETS)
|
||||
assert lines["S1"]["lines"] == [
|
||||
{"kind": "labor", "key": "L3", "qty": 1.3701},
|
||||
{"kind": "machine", "key": "X7", "qty": 14.3614},
|
||||
{"kind": "hopyo", "key": "S2", "qty": 0.5},
|
||||
]
|
||||
s = compute_summary(SG_SHEETS, lines)
|
||||
labor = {r["name"]: (r["qty"], r["amount"]) for r in s["sum_labor"]}
|
||||
# 엑셀 노무비수량금액집계표 6 · 9 줄(ROUND — 220.04 × 165,545 = 36,426,521.8 → …522)
|
||||
assert labor == {"석공": (137.01, 35476684), "보통인부": (220.04, 36426522)}
|
||||
# 재료비수량금액집계표 8 줄 폭약 4,025.97 × 12,712 = 51,178,130.6 → 51,178,131
|
||||
assert [(r["qty"], r["amount"]) for r in s["sum_material"]] == [(4025.97, 51178131)]
|
||||
machine = s["sum_machine"][0] # 중기시간금액집계표 10 줄 노무비
|
||||
assert (machine["qty"], machine["labor"], machine["sum"]) == (1436.14, 79992998, None)
|
||||
assert s["unlinked"] == 3 # 토공 · 대가 전 구조물 · 수량 빈칸 = 전개 안 함
|
||||
|
||||
Reference in New Issue
Block a user