Files
Aislo/B09_Estimation/B09_Estimation_Calc_Labor.py
T
eomsangdonandClaude Opus 5.5 a24e0d622c feat(M02·B09 고정 줄): 일위대가 줄 「고정」 갈래 · B09 대가 fixed 줄 · 이음 단가 · 집계표 빠짐 수 (PLAN 705-1)
- M02 일위대가 줄 {고정: 이름 · 규격 · 단위 · 단가 · 이음(노임 · 자재 · 기계) · 출처} — 정리 · 셈(새 파일 M02_Labor_Fixed) · 줄 가름 짝 TS lineId
- 사본 단가 묶음 · B09 단가 책 수요에 이음 키
- B09 대가 줄 fixed — 이은 비목만 단가 책 값 · 못 이음 = 줄 단가 · 덮개 먹음 · 빈칸 안 됨(파이썬 · TS 짝)
- 집계표 — 이은 고정 줄 = 자원 셈 · 이음 없는 줄 = 빠짐 수(summary.fixed · 안내 「고정 줄 n」)
- B09 일위대가표 줄 꼬리표 「고정」 값 넘김
- 시험 test_705_1 12 · test_145_5 응답 칸 목록에 fixed(낡은 단정 고침)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 14:05:47 +09:00

281 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""B09 일위대가표 · 일위대가목록표 셈(PLAN 145-2) — 화면 없음 · 저장 안 함 · 파일 · M01 안 읽음.
⚠ 짝 = `B09_Estimation_Calc_Labor.ts`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 ·
거울 시험 `resources/tester/test_145_2_b09_labor_calc.py`.
조작 중은 화면 짝이 바로 셈 · [저장] 때 서버가 이 파일로 정본을 다시 셈.
입력 = 대가 블록(`B09_Estimation_Labor_Source.daega_inputs` — 줄 꼴 · 수량만)
+ 바탕 표(144 굳힌 단가):
노무 줄 = 노무비목록표 단가 · 재료 줄 = 재료비목록표 단가 ·
중기 줄 = 중기목록표(노무 · 재료 · 경비)
대가 줄 = 부른 블록 합계(노무 · 재료 · 경비) · 산근 줄 = 145-1 산근 단가(`sangeun` {키: 값})
비고 = 대가 줄 「대가n」 · 산근 줄 「산근 n호표」(산근 목록 번호)
% 줄 = 밑수(그 블록 노무 줄 금액 합) × % / 100
고정 줄(705-1) = 줄 단가(`own`) · 이음(`link`) 있으면 그 비목만 단가 책 값(노임 = 노무 · 자재 =
재료(관급 · 별도 0원) · 기계 = 중기 세 비목) · 책에 없으면 줄 단가 그대로 · 빈칸 안 됨
끝수 = 엑셀 자리 그대로 — 줄 금액 = ROUNDDOWN(단가 × 수량, 1) ·
% 줄 = ROUNDDOWN(밑수 × % / 100, 1) · 관급 · 별도계상 자재 = 대가 안 0원 ·
블록 비목 합 = ROUNDDOWN(Σ, 0) · 합계 = 세 비목 합.
빈칸 = 단가 · 수량을 낸 원천이 없으면 None · 「연결 없음」 목록(gaps).
빈 줄(166-6) = 블록 합 = 아는 줄 합 + `blank` n · `blank_names` — 내역 단가 · 부른 대가로는 안 넘김.
고친 칸 덮개(702-2) = 사용자 값 `daega_cover` {블록 cover: {줄 cid: {qty · 노 · 재 · 경 단가}}} —
있는 칸만 셈 값 대신 · 줄 `base`(품셈 값 = 셈 수량 · 단가) · `covered`(고친 칸) ·
짝 잃은 덮개(그 블록 · 줄 없음) = `daega_orphans` [{cover, cid}] — 지우지 않고 알림만.
복사본(703-2) = 덮개 `K:…` 의 `@copy` {from · name · spec} — 원본 cover 블록을 본뜬 새 호표
(id `DG:K:…` · 번호 = 끝 · 줄 · 기본값 = 원본 · 덮개 = 제 것) · 원본 없음 = 짝 잃음 {cover, cid: @copy}.
"""
from __future__ import annotations
from typing import Any
from B09_Estimation.B09_Estimation_Calc_Base import SUPPLY, gap, num, rounddown, total
PARTS = ("labor", "material", "expense")
COVER_KEYS = ("qty", *PARTS)
TAB = "dg"
COPY = "@copy"
WHY = {
"labor": "노임 단가 없음",
"material": "자재 적용 단가 없음",
"machine": "중기 시간 단가 없음",
"daega": "부른 대가 합계 없음",
"sangeun": "산근 단가 없음(145-1)",
}
def _prices(line: dict[str, Any], books: dict[str, Any], sums: dict) -> dict[str, float | None]:
"""줄 비목 단가 — 그 비목이 없는 줄은 0 · 원천이 없으면 None."""
kind, key = line["kind"], line.get("key")
out: dict[str, float | None] = dict.fromkeys(PARTS, 0.0)
if kind == "labor":
out["labor"] = num((books["labor"].get(key) or {}).get("price"))
elif kind == "material":
row = books["material"].get(key) or {}
# 관급 · 별도계상 자재 = 대가 안 0원(자재대 · 관급 단락에서 셈 — 두 번 안 셈)
out["material"] = 0.0 if row.get("supply") in SUPPLY else num(row.get("price"))
elif kind in ("machine", "daega", "sangeun"):
row = {"machine": books["machine"], "daega": sums, "sangeun": books["sangeun"]}[kind].get(
key
) or {}
for part in PARTS:
out[part] = None if row.get("blank") else num(row.get(part)) # 빈 줄 대가 = 빈칸
elif kind == "fixed":
out = _fixed(line, books)
else: # blank — 못 펼친 줄
out = dict.fromkeys(PARTS, None)
return out
def _fixed(line: dict[str, Any], books: dict[str, Any]) -> dict[str, float | None]:
"""고정 줄 단가(705-1) — 줄 단가 위에 이은 비목만 단가 책 값."""
own, link = line.get("own") or {}, line.get("link") or {}
out = {part: num(own.get(part)) or 0.0 for part in PARTS}
labor = books["labor"].get(link.get("labor")) if link.get("labor") else None
if labor and num(labor.get("price")) is not None:
out["labor"] = num(labor["price"])
row = books["material"].get(link.get("material")) if link.get("material") else None
if row and (row.get("supply") in SUPPLY or num(row.get("price")) is not None):
out["material"] = 0.0 if row.get("supply") in SUPPLY else num(row["price"])
machine = books["machine"].get(link.get("machine")) if link.get("machine") else None
if machine and not machine.get("blank"):
for part in PARTS:
if num(machine.get(part)) is not None:
out[part] = num(machine[part])
return out
def _base(spec: str, lines: list[dict[str, Any]]) -> float | None:
"""% 줄 밑수 — `part:비목` 그 비목 줄 금액 합 · `lines:labor` 노무 줄 금액 합 ·
`line:이름` 그 줄."""
kind, _, name = spec.partition(":")
if kind == "part":
return total([ln["amounts"][name] for ln in lines])
if kind == "line":
return next(
(total(list(ln["amounts"].values())) for ln in lines if ln.get("name") == name), None
)
return total([ln["amounts"]["labor"] for ln in lines if ln["kind"] == "labor"])
def _cover(line: dict[str, Any], own: dict[str, Any], qty: Any, prices: dict) -> tuple:
"""덮개 한 줄 — (수량, 단가, 덧칸) · 덧칸 = `base`(품셈 값) · `covered`(고친 칸 차례)."""
mine = own.get(line.get("cid")) or {}
keys = [k for k in COVER_KEYS if k in mine]
if not keys:
return qty, prices, {}
extra = {"base": {"qty": qty, **prices}, "covered": keys}
qty = num(mine["qty"]) if "qty" in mine else qty
return qty, {p: num(mine[p]) if p in mine else v for p, v in prices.items()}, extra
def _block(
block: dict[str, Any],
books: dict[str, Any],
sums: dict[str, dict],
gaps: list,
own: dict[str, Any],
) -> dict[str, Any]:
lines = []
for line in block["lines"]:
if line["kind"] == "pct":
continue
qty, prices, extra = _cover(line, own, num(line.get("qty")), _prices(line, books, sums))
amounts = {
part: None if p is None or qty is None else rounddown(p * qty, 1)
for part, p in prices.items()
}
ref = {"ref": {"key": line["key"]}} if line.get("key") else {} # 집계표(145-5) 이음
lines.append({**line, **ref, "qty": qty, "prices": prices, "amounts": amounts, **extra})
if qty is None or None in prices.values():
why = line.get("why") or (
"수량 없음" if qty is None else WHY.get(line["kind"], "단가 없음")
)
gaps.append(gap(TAB, block["id"], f"{block['name']} · {line.get('name') or ''}", why))
for line in block["lines"]:
if line["kind"] != "pct":
continue
base = _base(line.get("base") or "lines:labor", lines)
mine = own.get(line.get("cid")) or {}
pct = num(mine["qty"]) if "qty" in mine else num(line.get("qty"))
extra = {"base": {"qty": num(line.get("qty"))}, "covered": ["qty"]} if "qty" in mine else {}
prices = dict.fromkeys(PARTS, 0.0)
amounts = dict.fromkeys(PARTS, 0.0)
part = line.get("part") or "expense"
prices[part] = base
amounts[part] = None if base is None or pct is None else rounddown(base * pct / 100, 1)
lines.append({**line, "qty": pct, "prices": prices, "amounts": amounts, **extra})
if amounts[part] is None:
gaps.append(
gap(TAB, block["id"], f"{block['name']} · {line.get('name')}", "% 밑수 없음")
)
for line in lines:
line["price"] = total(list(line["prices"].values()))
line["amount"] = total(list(line["amounts"].values()))
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", "cover")},
**({"spec_raw": block["spec_raw"]} if block.get("spec_raw") else {}), # 157-4 풍선
"key": block["id"],
"lines": lines,
**parts,
"sum": sum(parts.values()),
"blank": len(blank_names),
"blank_names": blank_names,
}
def with_copies(blocks: list[dict[str, Any]], cover: dict[str, Any]) -> list[dict[str, Any]]:
"""복사본(703-2) — 덮개 차례대로 `@copy` 마다 원본 블록을 본떠 끝에 더함(복사본의 복사본 됨)."""
out = list(blocks)
for key, lines in cover.items():
meta = (lines or {}).get(COPY)
if not isinstance(meta, dict):
continue
src = next((b for b in out if b.get("cover") == meta.get("from")), None)
if src is not None:
spec = meta["spec"] if "spec" in meta else src["spec"]
name = meta.get("name") or src["name"]
out.append({**src, "id": f"DG:{key}", "cover": key, "name": name, "spec": spec})
return out
def compute_labor(
daega: dict[str, Any] | None,
sheets: dict[str, Any],
sangeun: dict[str, Any] | None = None,
cover: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""대가 탭 둘 — `daega_blocks`(일위대가표) · `daega`(목록표) · `daega_owners`
+ 바탕 gaps 이어 붙임.
블록은 부른 블록을 먼저 셈(겹 대가) · 순환은 빈칸 · 번호 = 입력 차례(대가1 …)."""
daega = daega or {}
blocks = daega.get("blocks") or []
books = {
"labor": {r["key"]: r for r in sheets.get("labor") or []},
"material": {r["key"]: r for r in sheets.get("material") or []},
"machine": {r["key"]: r for r in sheets.get("machine") or []},
"sangeun": sangeun or {},
}
cover = cover or {}
blocks = with_copies(blocks, cover)
by_id = {b["id"]: b for b in blocks}
no = {b["id"]: n for n, b in enumerate(blocks, 1)}
done: dict[str, dict[str, Any]] = {}
gaps: list[dict[str, str]] = []
def run(bid: str, trail: tuple[str, ...]) -> None:
if bid in done or bid in trail or bid not in by_id:
return
for line in by_id[bid]["lines"]:
if line["kind"] == "daega":
run(line.get("key"), (*trail, bid))
done[bid] = _block(by_id[bid], books, done, gaps, cover.get(by_id[bid].get("cover")) or {})
for b in blocks:
run(b["id"], ())
out_blocks = []
for b in blocks:
got = {**done[b["id"]], "no": no[b["id"]]}
for line in got["lines"]:
if line["kind"] == "daega" and line.get("key") in no:
line["ref_no"] = f"대가{no[line['key']]}"
sg_no = (books["sangeun"].get(line.get("key")) or {}).get("no")
if line["kind"] == "sangeun" and sg_no is not None:
line["ref_no"] = f"산근 {sg_no}호표" # 155-3 비고(엑셀 꼴)
out_blocks.append(got)
listing = [
{
"no": b["no"],
"key": b["id"],
"kind": b["kind"],
"name": b["name"],
"spec": b["spec"],
**({"spec_raw": b["spec_raw"]} if b.get("spec_raw") else {}),
"unit": b["unit"],
"sum": b["sum"],
"labor": b["labor"],
"material": b["material"],
"expense": b["expense"],
"blank": b["blank"],
"blank_names": b["blank_names"],
}
for b in out_blocks
]
cids: dict[str, set] = {}
for b in blocks:
cids.setdefault(b.get("cover"), set()).update(ln.get("cid") for ln in b["lines"])
orphans = [
{"cover": key, "cid": cid}
for key, lines in cover.items()
for cid in lines
if cid not in cids.get(key, ()) and not (cid == COPY and f"DG:{key}" in by_id)
]
return {
"daega_blocks": out_blocks,
"daega": listing,
"daega_owners": dict(daega.get("owners") or {}),
"daega_orphans": orphans,
"gaps": [*(sheets.get("gaps") or []), *gaps],
}
def daega_price(sheets: dict[str, Any], owner: str) -> dict[str, Any] | None:
"""내역서(145-3)가 읽는 대가 단가 — 구조물 id(연결된 사본 주인) → 호표 · 비목 단가.
사본이 없으면 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:
return None
return {
"no": f"대가{row['no']}",
"key": bid,
**{
k: None if row.get("blank") else row[k] for k in ("labor", "material", "expense", "sum")
},
}