- 새 B09_Estimation_Labor_Rule: 새 의미 로직만 대가 블록 줄 `place`(줄 자리) · `out`(합밖 · 제외 단계) · 덧줄 = 수 하나 × 밑 합 꼴이면 `rows:` % 줄(줄.'이름' · 앞 덧줄 · 번지 · 단계 · 비목 합 · 793-2a 괄호 꼴) · 이름 없는 수 식 덧줄 = 고정 줄 · 블록 `rule` {끝전, 조정, 끝수} · 다른 꼴 = 빈 줄 그대로(틀린 값 대신 빈칸)
- Labor_Source: 품셈 블록에 표지 · 단가산출 블록이 줄을 바꿔도 표지 이어 받음(657 줄)
- Calc_Labor .py · .ts 짝: 줄 금액 `cut`(place) · `out` 줄은 비목 합 · part: 밑에서 뺌 · 블록 비목 합 `settle`(큰나머지 → 끝수 → 조정 → 원 버림 · master_cut.settle 차례) · 옛 블록 = 표지 없음 = 전과 같음
- 산근 글 로직 부르기 줄(중기 세 줄 · 하위 산근) 금액도 줄 자리 규칙(792-3 남은 것)
- test_792_3: 다섯째 길 B09 대가(SC → 굳힌 SK 수량 1 → 내역 단가 · 파이썬 = TS 거울) · 실무 봉화 D00102 · 북부청임도 18 을 793-2 길(임시 층)로 가져온 식 SK → 내역 단가 = 엑셀
- test_712_3 낡은 기대: 표 비율 0.78 → 단위 수 1,601 이상 · 0.76 — 782-1 · 782-2 · 782-3 계수 표 94 가 로직에 붙음(그중 단위 기준 25 · 시공능력 · 적용기준 계수 = 단위 기준 없음) · 712-3 때 2,023 중 1,601 그대로 · 마스터 자료 안 고침
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
384 lines
18 KiB
Python
384 lines
18 KiB
Python
"""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", *PARTS)
|
||
TAB = "dg"
|
||
NO_LINES = "구성 줄 없음" # 줄 0 개 일위대가의 빈 줄 이름
|
||
COST = {"노무비": "labor", "재료비": "material", "경비": "expense"}
|
||
WAYS = {"버림": rounddown, "올림": roundup, "반올림": round_half}
|
||
COPY = "@copy"
|
||
ADD = "@add"
|
||
WHY = {
|
||
"labor": "노임 단가 없음",
|
||
"material": "자재 적용 단가 없음",
|
||
"machine": "중기 시간 단가 없음",
|
||
"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 == "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]], 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":
|
||
return total([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}
|
||
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 cut(p * qty, line.get("place"))
|
||
for part, p in prices.items()
|
||
}
|
||
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
|
||
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")
|
||
},
|
||
}
|