Files
Aislo/B09_Estimation/B09_Estimation_Calc_Labor.py
T
eomsangdonandClaude Opus 5.5 5d8d9a4fd3 test(841-1): B09 = M02 값 — 산근 · 대가 · 고정 줄 셈 다듬기 · 낡은 시험 정리 — C 걸음
코드
- 고정 줄 단가 = M02_Labor_Fixed.line(사본 묶음 · 이은 노임 · 자재 = 묶음 값) — B09 단가 책으로 다시 안 덮음
- 대가 · 산근 고친 칸 = 품만 실제로(단가 칸 덮기 줄 남던 흠 · 품 고친 뒤 줄 금액 다시 셈 흠 py · ts 짝)
- 산근 글 = 엔진 단가 셈 그대로 · 별도계상 재료 줄 끝 「· 별도계상 = 0.0」 · 계 줄 = 블록 합
- 노무비 · 중기목록 이름에서 규격 겹침 뗌 · TS 바탕 셈 이름 PARTS → MACHINE_PARTS(짝 시험이 TS 를 이어 붙일 때 겹침)

시험(고친 까닭 = 단가 원천이 B09 책 → M02 엔진 · 노임 · 기계 덮개 · 단가 고친 칸 · 중기사용료 걷음)
- 144 바탕 · 뼈대: 운전사 · 중기사용료 · 경비 · 덮개 시험 걷음 → 노무 · 중기 나열 · 사급 표시 · 자재만 책
- 145-1 · 145-2 · 155 · 157-1 · 166-2 · 166-6 · 719-3 · 719-4 · 723-4 · 778-4 · 792-3: 줄 단가 = own(엔진) · 산근 = Solver() · collect 새 부름 꼴
- 702 · 705-1 · 719-2: 단가 칸 덮개 = 안 읽음 · 고정 줄 = M02 셈(노임 새 판 따라가기 시험 걷음)
- 203-1 · 296 · 703-3 · 795-4: 단가 책 = 자재 줄로
- 707 · 707-6 · 707-7: 단가표 불러오기 = 자재만 · 거창 이은 안내판 줄 = M02 사본 셈(엑셀 −50,000)
- 201-1: 환율 · 중기사용료 표 걷음 · b09 저장 속도: 산근 앞 풀이 그대로(단가 표 같음 견줌 걷음)
- 대상 113 파일 764 통과 · 25 실패 = 원래 실패(formatMoney · formatPrice 없음 · 단위 글 ㎜ · sec · 719-1 · 796-2e · 717-4 · 721 · 311 · 243 · 246)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 16:57:01 +09:00

404 lines
18 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 · 율 더한줄(778-4) 밑수 = `bundle:` 묶음 줄 합 ·
`rows:` 블록 몇째 줄 합(`_base`)
고정 줄(705-1) = 줄 단가(`own`) · 이음(`link`) 있으면 그 비목만 단가 책 값(노임 = 노무 · 자재 =
재료(관급 · 별도 0원) · 기계 = 중기 세 비목) · 책에 없으면 줄 단가 그대로 · 빈칸 안 됨
끝수 = 엑셀 자리 그대로 — 줄 금액 = ROUNDDOWN(단가 × 수량, 1) ·
% 줄 = ROUNDDOWN(밑수 × % / 100, 1) · 관급 · 별도계상 자재 = 대가 안 0원 ·
블록 비목 합 = ROUNDDOWN(Σ, 0) · 합계 = 세 비목 합.
새 의미 로직 블록(792-4 · `B09_Estimation_Labor_Rule`) = 줄 `place` {방법, 자리}(그 끝수) ·
줄 `out`(비목 합 · `part:` 밑에서 뺌) · 블록 `rule` {끝전 큰나머지, 끝수, 조정}(`settle` ·
엔진 `master_cut.settle` 과 같은 차례).
빈칸 = 단가 · 수량을 낸 원천이 없으면 None · 「연결 없음」 목록(gaps).
빈 줄(166-6) = 블록 합 = 아는 줄 합 + `blank` n · `blank_names` — 내역 단가 · 부른 대가로는 안 넘김 ·
줄 0 개 블록 = 빈 줄 하나(`NO_LINES`) · 줄 `ask`(723-4 SC 안 줄 수량 없음) = 수량이 비면 `why`.
고친 칸 덮개(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}.
붙인 줄(705-2 · 엑셀 붙여넣기) = 덮개 `@add` [블록 줄 `fixed` · `pct` · cid `@add|…`] — 그 cover 블록 끝에 더함
(복사본 앞 · 뒤 두 번 · 같은 cid 는 한 번 · 복사본이 원본 붙인 줄을 이어받음) · 고친 칸 덮개는 다른 줄과 같음.
"""
from __future__ import annotations
from typing import Any
from B09_Estimation.B09_Estimation_Calc_Base import (
SUPPLY,
gap,
num,
round_half,
rounddown,
roundup,
total,
)
PARTS = ("labor", "material", "expense")
COVER_KEYS = ("qty",) # 841-1 단가 고친 칸 걷음 — 품(수량)만
TAB = "dg"
NO_LINES = "구성 줄 없음" # 줄 0 개 일위대가의 빈 줄 이름
COST = {"노무비": "labor", "재료비": "material", "경비": "expense"}
WAYS = {"버림": rounddown, "올림": roundup, "반올림": round_half}
COPY = "@copy"
ADD = "@add"
WHY = {
"labor": "노임 단가 없음(M02 엔진)",
"material": "자재 단가 없음(M02 엔진)",
"machine": "기계 시간당 없음(M02 엔진)",
"daega": "부른 대가 합계 없음",
"sangeun": "산근 단가 없음(145-1)",
}
def cut(value: float, place: dict | None) -> float:
"""줄 금액 끝수 — `place` {방법, 자리}(792-4) · 없으면 ROUNDDOWN 1."""
if not place:
return rounddown(value, 1)
return WAYS[place.get("방법")](value, int(place.get("자리") or 0))
def settle(raw: dict[str, float], rule: dict | None) -> dict[str, float]:
"""블록 비목 합 — 비목마다 ROUNDDOWN 0 · `rule`(792-4) = 큰나머지(계 원 버림 뒤 모자란 원을
소수 큰 비목부터 · 같으면 경 › 재 › 노) → `끝수`(대상 계) → `조정`(배율 · 옮김) → 원 버림."""
parts = {p: rounddown(v, 0) for p, v in raw.items()}
if not rule:
return parts
if rule.get("끝전") == "큰나머지":
rest = int(rounddown(sum(raw.values()), 0) - sum(parts.values()))
for p in sorted(PARTS, key=lambda p: (parts[p] - raw[p], -PARTS.index(p)))[:rest]:
parts[p] += 1
end = rule.get("끝수")
if isinstance(end, dict) and end.get("대상") == "계":
parts = {p: cut(v, end) for p, v in parts.items()}
adjust = rule.get("조정") or {}
for name, rate in (adjust.get("배율") or {}).items():
parts[COST[name]] *= float(rate)
if adjust.get("옮김"):
whole = sum(parts.values())
parts = {p: whole if p == COST[adjust["옮김"]] else 0.0 for p in PARTS}
return {p: rounddown(v, 0) for p, v in parts.items()}
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 in (
"labor",
"material",
"machine",
): # 841-1 = M02 엔진 단가(`own`) · B09 단가 책 안 읽음
own = line.get("own")
out = {p: num(own.get(p)) if p in own else 0.0 for p in PARTS} if own else _none()
elif kind in ("daega", "sangeun"):
row = {"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 _apart(line: dict[str, Any], books: dict[str, Any]) -> bool:
"""대가 밖 재료(841-1) — 대비표 표시(사용자 › M02 자재표시 기본) 관급 · 별도 = 자재대 · 관급 단락."""
return (books["material"].get(line.get("key")) or {}).get("supply") in SUPPLY
def _as_m02(block: dict, lines: list[dict], books: dict, own: dict) -> bool:
"""SK 블록 합을 M02 셈으로 — 고친 칸 · 붙인 줄 없음 · 재료 표시가 M02 자재표시 그대로일 때만."""
return (
bool(block.get("m02"))
and not own
and all(
_apart(ln, books) == (ln.get("mark") in SUPPLY)
for ln in lines
if ln["kind"] == "material"
)
)
def _none() -> dict[str, float | None]:
return dict.fromkeys(PARTS, None)
def _fixed(line: dict[str, Any], books: dict[str, Any]) -> dict[str, float | None]:
"""고정 줄 단가(705-1) — 줄 단가 그대로 · 이은 자재가 관급 · 별도계상이면 재료 0(841-1 단가 책
값으로 다시 안 덮음)."""
own, link = line.get("own") or {}, line.get("link") or {}
out = {part: num(own.get(part)) or 0.0 for part in PARTS}
row = books["material"].get(link.get("material")) if link.get("material") else None
if row and row.get("supply") in SUPPLY:
out["material"] = 0.0
return out
def _base(spec: str, lines: list[dict[str, Any]], src: list) -> float | None:
"""% 줄 밑수 — `part:비목` 그 비목 줄 금액 합 · `lines:labor` 노무 줄 금액 합 ·
`line:이름` 그 줄 · `bundle:labor|material|machine` 그 종류 줄(+ 그 이음 고정 줄) 합 ·
`rows:0,2@labor` 블록 그 차례 줄(`src` cid · `@비목` = 그 몫) 합(778-4 · 짝 `.ts` `pctBase`)."""
kind, _, name = spec.partition(":")
if kind == "part": # 대가 밖 재료(841-1 `aside`)도 밑수에 — M02 엔진 「재료비의 %」 꼴
aside = "aside" if name == "material" else ""
return total([ln.get(aside) or ln["amounts"][name] for ln in lines if not ln.get("out")])
if kind == "rows": # `3@labor` = 그 줄 그 비목 몫
out: list[float | None] = []
for one in dict.fromkeys(name.split(",")):
at, _, part = one.partition("@")
if not at.isdigit() or int(at) >= len(src):
continue
cid = src[int(at)].get("cid")
out += [
ln["amounts"].get(part) if part else total(list(ln["amounts"].values()))
for ln in lines
if ln.get("cid") == cid
]
return total(out)
if kind == "bundle":
return total(
[
total(list(ln["amounts"].values()))
for ln in lines
if ln["kind"] == name
or (ln["kind"] == "fixed" and (ln.get("link") or {}).get(name))
]
)
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}
return num(mine["qty"]), dict(prices), extra # 841-1 품만(단가 칸 = 안 읽음)
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 cut(p * qty, line.get("place"))
for part, p in prices.items()
}
if line["kind"] == "material" and _apart(line, books): # 관급 · 별도계상 = 대가 안 0원
extra["aside"], prices["material"], amounts["material"] = amounts["material"], 0.0, 0.0
ref = {"ref": {"key": line["key"]}} if line.get("key") else {} # 집계표(145-5) 이음
ask = {"why": line["ask"]} if qty is None and line.get("ask") else {} # 723-4
lines.append(
{**line, **ref, **ask, "qty": qty, "prices": prices, "amounts": amounts, **extra}
)
if qty is None or None in prices.values():
why = (
line.get("why")
or ask.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, block["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 cut(base * pct / 100, line.get("place"))
)
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 and not ln.get("out")]
parts = settle({part: sum(ln["amounts"][part] for ln in known) for part in PARTS},
block.get("rule")) # fmt: skip
if _as_m02(
block, lines, books, own
): # 841-1 단가산출 블록 = M02 SK 셈 그대로(덮음 · 수식 고침)
parts = {p: rounddown(block["m02"][p], 0) for p in PARTS}
blank_names = [ln.get("name") or "" for ln in lines if ln["amount"] is None]
if not lines: # 줄 0 개 일위대가 = 금액 0 이 아니라 모름(빈 줄)
blank_names = [NO_LINES]
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 with_added(blocks: list[dict[str, Any]], cover: dict[str, Any]) -> list[dict[str, Any]]:
"""붙인 줄(705-2) — 블록 cover 의 `@add` 줄을 끝에 더함(이미 있는 cid 는 뺌)."""
out = []
for b in blocks:
add = (cover.get(b.get("cover")) or {}).get(ADD)
have = {ln.get("cid") for ln in b["lines"]}
more = [ln for ln in add if ln.get("cid") not in have] if isinstance(add, list) else []
out.append({**b, "lines": [*b["lines"], *more]} if more else b)
return out
def added_keys(cover: dict[str, Any] | None) -> list[str]:
"""붙인 줄이 잇는 단가 책 키(705-2 · 수요에 더함)."""
out: list[str] = []
for lines in (cover or {}).values():
for ln in (lines or {}).get(ADD) or []:
out += [v for v in (ln.get("link") or {}).values() if v and v not in out]
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_added(with_copies(with_added(blocks, cover), cover), 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)
and cid != ADD
]
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")
},
}