Merge remote-tracking branch 'origin/sub_desktop_1' into sub_laptop_6
# Conflicts: # B09_Estimation/B09_Estimation_Router.py # B09_Estimation/B09_Estimation_UI_Shell.ts
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
"""B09 설계내역서 항목 짜기(PLAN 145-3) — 서버 단독 · B08 수량 → 내역 줄(수량 · 단가 자리).
|
||||
|
||||
공종 나무 = 엑셀 기번3 공통 꼴 고정(`B09_Estimation_Calc_Bill.SECTIONS`) · 공구 안 나눔.
|
||||
토공(1.1~1.6) = B08 토공집계표 줄 짝(`EARTH` — 엑셀 항목 이름 ↔ 토공집계표 구분 · 종류 · 규격) ·
|
||||
단가 = 조합 밖 토공 산근 · 준비공 대가 — 원천 없음 → 빈칸 「연결 없음」(지시)
|
||||
구조물(2.1~2.3) = B08 구조물 집계표 열(사본 있을 때만 · 143) ·
|
||||
구조물군 → 공종 짝(`STRUCTURE_SECTION`)
|
||||
· 커플링 열은 자재(관급자재대)라 뺌 · 단가 = 대가 호표(145-2 가 `hopyo` 로 줌 · 그전엔 빈칸)
|
||||
운반(3) = 엑셀 운반공 줄 · 수량 = B08 총괄자재 짝 · 거리 · 단가 = 운반 산근(원천 없음 → 빈칸)
|
||||
자재대 · 관급자재대 = 화면 짝이 대비표 「별도」 · 「관급」 표시로 짬(사용자 조작 즉시)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from B08_Quantity.B08_Quantity_Material_Calc import _structure_row
|
||||
|
||||
WHY_EARTH = "조합 밖 토공 산근 — 연결 없음"
|
||||
WHY_READY = "준비공 대가 — 연결 없음"
|
||||
WHY_HAUL = "운반 산근 — 연결 없음(운반거리 원천 없음)"
|
||||
WHY_STRUCTURE = "대가 호표 — 연결 없음(일위대가 145-2)"
|
||||
|
||||
#: 토공 항목 — (공종, 엑셀 명칭, 단위, 토공집계표 (구분, 종류, 규격 | None) | None)
|
||||
EARTH: list[tuple[str, str, str, tuple[str, str, str | None] | None]] = [
|
||||
("1.1", "규준틀설치 종단규준틀", "개소", None),
|
||||
("1.1", "규준틀설치 횡단규준틀", "개소", None),
|
||||
("1.1", "제근", "㎡", None),
|
||||
("1.2", "절토(토사)", "㎥", ("흙깍기", "토사", None)),
|
||||
("1.2", "절토(연암)", "㎥", ("흙깍기", "연암", None)),
|
||||
("1.2", "암발파", "㎥", ("흙깍기", "경암", None)),
|
||||
("1.3", "구조물터파기(토사)", "㎥", ("구조물터파기", "토사", None)),
|
||||
("1.3", "구조물터파기(연암)", "㎥", ("구조물터파기", "연암", None)),
|
||||
("1.3", "구조물 되메우기", "㎥", ("되메우기", "", None)),
|
||||
("1.4", "측구파기(토사)", "㎥", ("측구 터파기", "토사", None)),
|
||||
("1.4", "측구파기(연암)", "㎥", ("측구 터파기", "연암", None)),
|
||||
("1.5", "유용토운반", "㎥", ("유용토 소계", "", None)),
|
||||
("1.5", "도자운반", "㎥", ("토사운반", "", "19 TON B/D")),
|
||||
("1.5", "덤프운반", "㎥", ("토사운반", "", "BH+DT+BH")),
|
||||
("1.6", "사토처리", "㎥", ("잔토처리", "", None)),
|
||||
("1.6", "성토사면다짐", "㎡", ("성토면다짐", "", None)),
|
||||
("1.6", "노면정리", "㎡", None),
|
||||
]
|
||||
|
||||
#: 구조물 집계표 열 id 머리 → 공종(그 밖 = 2.2 배수공) · 커플링 = 자재라 뺌
|
||||
STRUCTURE_SECTION = {"pipe:revet|": "2.1", "pipe:chute|": "2.3"}
|
||||
SKIP_COLUMNS = ("pipe:cp|",)
|
||||
|
||||
#: 운반공 — (명칭, 단위, B08 총괄자재 줄 키(합) | None)
|
||||
HAUL: list[tuple[str, str, tuple[str, ...] | None]] = [
|
||||
("중기운반", "대", None),
|
||||
("기계운반", "대", None),
|
||||
("모래운반", "㎥", ("sand",)),
|
||||
("자갈운반", "㎥", ("gravel",)),
|
||||
("시멘트운반", "대", ("cement",)),
|
||||
("철근운반", "ton", ("rebar_d13", "rebar_d16", "rebar_d19", "rebar_d22", "rebar_d25")),
|
||||
]
|
||||
|
||||
|
||||
def _same(row: dict[str, Any], want: tuple[str, str, str | None]) -> bool:
|
||||
group, kind, spec = want
|
||||
same = row.get("group") == group and (row.get("kind") or "") == kind
|
||||
return same and (spec is None or row.get("spec") == spec)
|
||||
|
||||
|
||||
def earth_items(earthwork: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
rows = (earthwork or {}).get("summary") or []
|
||||
out = []
|
||||
for n, (section, name, unit, want) in enumerate(EARTH, 1):
|
||||
source = next((r for r in rows if want and _same(r, want)), None)
|
||||
out.append(
|
||||
{
|
||||
"key": f"earth:{n}",
|
||||
"section": section,
|
||||
"name": name,
|
||||
"spec": (source or {}).get("spec") or "",
|
||||
"unit": unit,
|
||||
"qty": source.get("value") if source else None,
|
||||
"note": "B08 토공집계표" if source else "",
|
||||
"ref": None,
|
||||
"why": WHY_READY if section == "1.1" else WHY_EARTH,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def structure_items(summary: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
columns = (summary or {}).get("columns") or []
|
||||
total = (summary or {}).get("total") or {}
|
||||
out = []
|
||||
for column in columns:
|
||||
cid = str(column.get("id") or "")
|
||||
if cid.startswith(SKIP_COLUMNS):
|
||||
continue
|
||||
section = next((s for head, s in STRUCTURE_SECTION.items() if cid.startswith(head)), "2.2")
|
||||
name, spec, unit = _structure_row(column)
|
||||
combos = [c for c in column.get("combos") or [] if c]
|
||||
out.append(
|
||||
{
|
||||
"key": f"st:{cid}",
|
||||
"section": section,
|
||||
"name": name,
|
||||
"spec": spec,
|
||||
"unit": unit,
|
||||
"qty": total.get(cid),
|
||||
"note": " · ".join(combos),
|
||||
"ref": {"tab": "dg_list", "key": cid},
|
||||
"why": WHY_STRUCTURE,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def haul_items(materials: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
rows = ((materials or {}).get("material") or {}).get("rows") or []
|
||||
amount = {row.get("key"): row.get("amount") for row in rows}
|
||||
out = []
|
||||
for n, (name, unit, keys) in enumerate(HAUL, 1):
|
||||
qty = None
|
||||
if keys:
|
||||
found = [amount[k] for k in keys if amount.get(k) is not None]
|
||||
qty = round(sum(found), 6) if found else None
|
||||
out.append(
|
||||
{
|
||||
"key": f"haul:{n}",
|
||||
"section": "3",
|
||||
"name": name,
|
||||
"spec": "",
|
||||
"unit": unit,
|
||||
"qty": qty,
|
||||
"note": "B08 총괄자재" if qty is not None else "",
|
||||
"ref": None,
|
||||
"why": WHY_HAUL,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def bill_items(b08: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"items": [
|
||||
*earth_items(b08.get("earthwork")),
|
||||
*structure_items(b08.get("summary")),
|
||||
*haul_items(b08.get("materials")),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def hopyo_prices(project_root: Any) -> dict[str, dict[str, float]]:
|
||||
"""호표 단가 `{호표 자리 키: {labor, material, expense}}` —
|
||||
145-1 · 2(산근 · 대가)가 들어오면 채움 · 그전엔 빈 묶음(구조물 단가 = 빈칸 「연결 없음」)."""
|
||||
return {}
|
||||
@@ -0,0 +1,208 @@
|
||||
"""B09 설계내역서 셈(PLAN 145-3) — 화면 없음 · 저장 안 함 · 파일 · M01 안 읽음.
|
||||
|
||||
⚠ 짝 = `B09_Estimation_Calc_Bill.ts`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 ·
|
||||
거울 시험 `resources/tester/test_145_3_b09_bill.py`.
|
||||
|
||||
실무 엑셀 설계내역서 꼴 · 공종 나무 = 엑셀 꼴 고정(`SECTIONS` · 공구 안 나눔):
|
||||
1 토공사(1.1~1.6) · 2 구조물공(2.1~2.3) · 3 운반공 · 4 자재대 · 관급자재대(제외금액)
|
||||
항목(토공 · 구조물 · 운반) = 서버가 B08 수량으로 짠 줄(`B09_Estimation_Bill_Items`)
|
||||
4 자재대 = 자재단가대비표에서 「별도」 표시한 B08 자재 · 관급자재대 = 「관급」 표시 + 조달수수료
|
||||
단가 = 호표(대가 · 산근 — 145-1 · 2 가 `inputs.hopyo` 로 줌) · 자재 = 대비표 적용 단가
|
||||
금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 합계 = 세 비목 합 ·
|
||||
조달수수료 = ROUNDDOWN(관급 순자재대 × 율 ÷ 100, 0)(율 = 사용자 입력 · 빈칸 시작)
|
||||
제목 줄 = 아래 항목 금액 합(엑셀 SUMIF — 빈 줄은 0 이 아니라 「빈 줄 n」 으로 셈)
|
||||
빈칸 = 수량이나 단가를 낸 원천이 없으면 그 줄 금액 None + 「연결 없음」 목록.
|
||||
원가계산서로 넘길 합 = `sums`(직접재료비 · 직접노무비 · 산출경비 · 관급 순자재대 — 145-4 이음 꼴 ·
|
||||
빈 줄이 있으면 None) · 화면 표시용 = `totals`(아는 금액 합 · 빈 줄 수 · 조달수수료).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from B09_Estimation.B09_Estimation_Calc_Base import compute_base, gap, num, rounddown
|
||||
|
||||
#: 공종 나무 — (번호, 이름, 부모) · 엑셀 기번3 공통 1공구 꼴(공구 안 나눔)
|
||||
SECTIONS: list[tuple[str, str, str | None]] = [
|
||||
("1", "토공사", None),
|
||||
("1.1", "준비공", "1"),
|
||||
("1.2", "절토", "1"),
|
||||
("1.3", "구조물터파기 및 되메우기", "1"),
|
||||
("1.4", "측구파기", "1"),
|
||||
("1.5", "유용성토", "1"),
|
||||
("1.6", "사토 및 다짐공", "1"),
|
||||
("2", "구조물공", None),
|
||||
("2.1", "돌기슭막이공", "2"),
|
||||
("2.2", "배수공", "2"),
|
||||
("2.3", "기타구조물", "2"),
|
||||
("3", "운반공", None),
|
||||
("4", "자재대", None),
|
||||
("G", "관급자재대", None),
|
||||
]
|
||||
#: 순공사비에 드는 맨 위 공종(관급자재대 = 제외금액)
|
||||
DIRECT = ("1", "2", "3", "4")
|
||||
PARTS = ("labor", "material", "expense")
|
||||
|
||||
|
||||
def _amounts(qty: float | None, prices: dict[str, float | None] | None) -> dict[str, Any]:
|
||||
if prices is None:
|
||||
unit = {p: None for p in PARTS}
|
||||
else:
|
||||
unit = {p: num(prices.get(p)) for p in PARTS}
|
||||
out: dict[str, Any] = {f"price_{p}": unit[p] for p in PARTS}
|
||||
known = all(unit[p] is not None for p in PARTS)
|
||||
out["price_sum"] = unit["labor"] + unit["material"] + unit["expense"] if known else None
|
||||
for p in PARTS:
|
||||
out[p] = rounddown(qty * unit[p], 0) if qty is not None and unit[p] is not None else None
|
||||
filled = all(out[p] is not None for p in PARTS)
|
||||
out["sum"] = out["labor"] + out["material"] + out["expense"] if filled else None
|
||||
return out
|
||||
|
||||
|
||||
def _item_row(item: dict[str, Any], prices: dict[str, Any] | None, gaps: list) -> dict[str, Any]:
|
||||
qty = num(item.get("qty"))
|
||||
row = {
|
||||
"kind": "item",
|
||||
"key": item["key"],
|
||||
"section": item["section"],
|
||||
"name": item.get("name") or "",
|
||||
"spec": item.get("spec") or "",
|
||||
"unit": item.get("unit") or "",
|
||||
"qty": qty,
|
||||
"note": item.get("note") or "",
|
||||
"ref": item.get("ref"),
|
||||
**_amounts(qty, prices),
|
||||
}
|
||||
if qty is None:
|
||||
gaps.append(gap("bill", item["key"], row["name"], "수량 연결 없음"))
|
||||
if prices is None:
|
||||
gaps.append(gap("bill", item["key"], row["name"], item.get("why") or "단가 연결 없음"))
|
||||
return row
|
||||
|
||||
|
||||
def _material_items(inputs: dict[str, Any], compare: list[dict[str, Any]]) -> list[dict]:
|
||||
"""대비표에서 「별도」 · 「관급」 표시한 수량 있는 자재 → 4 자재대 · 관급자재대 항목."""
|
||||
qty_of = {m["key"]: m.get("qty") for m in inputs.get("materials") or [] if "qty" in m}
|
||||
out = []
|
||||
for row in compare:
|
||||
if row["supply"] is None or row["key"] not in qty_of:
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"key": f"mat:{row['key']}",
|
||||
"section": "G" if row["supply"] == "관급" else "4",
|
||||
"name": row["name"],
|
||||
"spec": row["spec"],
|
||||
"unit": row["unit"],
|
||||
"qty": qty_of[row["key"]],
|
||||
"note": f"자재 {row['no']}",
|
||||
"ref": {"tab": "material", "key": row["key"]},
|
||||
"_price": row["applied"],
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def compute_bill(
|
||||
inputs: dict[str, Any], user: dict[str, Any], base: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
gaps: list[dict[str, str]] = []
|
||||
hopyo = inputs.get("hopyo") or {}
|
||||
items: list[dict[str, Any]] = []
|
||||
for item in (inputs.get("bill") or {}).get("items") or []:
|
||||
ref = item.get("ref") or {}
|
||||
prices = hopyo.get(ref.get("key")) if ref.get("key") else None
|
||||
items.append(_item_row(item, prices, gaps))
|
||||
for item in _material_items(inputs, base["compare"]):
|
||||
price = item["_price"]
|
||||
prices = None if price is None else {"labor": 0, "material": price, "expense": 0}
|
||||
items.append(_item_row(item, prices, gaps))
|
||||
owner = [row for row in items if row["section"] == "G"]
|
||||
net = 0.0
|
||||
for row in owner:
|
||||
net += row["material"] or 0
|
||||
pct = num(((user.get("estimate") or {}).get("procurement_fee_pct")))
|
||||
fee = rounddown(net * pct / 100, 0) if pct is not None else None
|
||||
fee_row = {
|
||||
"kind": "item",
|
||||
"key": "fee",
|
||||
"section": "G",
|
||||
"name": "조달수수료",
|
||||
"spec": "관급 재료비 × 율",
|
||||
"unit": "%",
|
||||
"qty": pct,
|
||||
"note": "관급 순자재대 밑수",
|
||||
"ref": None,
|
||||
"price_labor": 0,
|
||||
"price_material": net,
|
||||
"price_expense": 0,
|
||||
"price_sum": net,
|
||||
"labor": 0 if pct is not None else None,
|
||||
"material": fee,
|
||||
"expense": 0 if pct is not None else None,
|
||||
"sum": fee,
|
||||
}
|
||||
if owner:
|
||||
items.append(fee_row)
|
||||
if pct is None:
|
||||
gaps.append(gap("bill", "fee", "조달수수료", "조달수수료율 입력 없음"))
|
||||
rows: list[dict[str, Any]] = []
|
||||
sums: dict[str, dict[str, Any]] = {}
|
||||
for code, name, parent in SECTIONS:
|
||||
sums[code] = {"sum": 0.0, "labor": 0.0, "material": 0.0, "expense": 0.0, "blank": 0}
|
||||
for row in items:
|
||||
code = row["section"]
|
||||
chain = [code]
|
||||
parent = next(p for c, _n, p in SECTIONS if c == code)
|
||||
if parent:
|
||||
chain.append(parent)
|
||||
for c in chain:
|
||||
for p in ("sum", *PARTS):
|
||||
sums[c][p] += row[p] or 0
|
||||
if row["sum"] is None:
|
||||
sums[c]["blank"] += 1
|
||||
for code, name, parent in SECTIONS:
|
||||
s = sums[code]
|
||||
rows.append(
|
||||
{
|
||||
"kind": "title",
|
||||
"key": f"sec:{code}",
|
||||
"section": code,
|
||||
"name": f"{code} {name}" if code != "G" else name,
|
||||
"sum": s["sum"],
|
||||
"labor": s["labor"],
|
||||
"material": s["material"],
|
||||
"expense": s["expense"],
|
||||
"blank": s["blank"],
|
||||
"note": ("(제외금액)" if code == "G" else "")
|
||||
+ (f" 빈 줄 {s['blank']}" if s["blank"] else ""),
|
||||
}
|
||||
)
|
||||
rows.extend(row for row in items if row["section"] == code)
|
||||
direct = {p: 0.0 for p in ("sum", *PARTS)}
|
||||
blank = 0
|
||||
for code in DIRECT:
|
||||
for p in direct:
|
||||
direct[p] += sums[code][p]
|
||||
blank += sums[code]["blank"]
|
||||
totals = {
|
||||
"direct": {**direct, "blank": blank},
|
||||
"owner": {"net_material": net, "fee": fee, "total": net + (fee or 0)},
|
||||
}
|
||||
# 원가계산서로 넘김(145-4 이음 꼴) — 빈 줄이 있으면 모르는 값 = None
|
||||
known = blank == 0
|
||||
owner_known = all(row["sum"] is not None for row in owner)
|
||||
sums_out = {
|
||||
"material": direct["material"] if known else None,
|
||||
"labor": direct["labor"] if known else None,
|
||||
"expense": direct["expense"] if known else None,
|
||||
"supply_net": net if owner_known else None,
|
||||
}
|
||||
return {"rows": rows, "totals": totals, "sums": sums_out, "gaps": gaps}
|
||||
|
||||
|
||||
def compute_estimation(inputs: dict[str, Any], user: dict[str, Any]) -> dict[str, Any]:
|
||||
"""144 바탕 + 145-3 설계내역서 — 「연결 없음」 목록은 둘을 이음."""
|
||||
base = compute_base(inputs, user)
|
||||
bill = compute_bill(inputs, user, base)
|
||||
return {**base, "bill": bill, "gaps": base["gaps"] + bill["gaps"]}
|
||||
@@ -0,0 +1,197 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_Calc_Bill.ts
|
||||
* B09 설계내역서 셈(PLAN 145-3) — 화면 없음 · 저장 안 함 · import 는 짝 바탕 셈 하나(시험이 이어 붙임).
|
||||
*
|
||||
* ⚠ 짝 = `B09_Estimation_Calc_Bill.py`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 ·
|
||||
* 거울 시험 `resources/tester/test_145_3_b09_bill.py`.
|
||||
*
|
||||
* 공종 나무 = 엑셀 꼴 고정(1 토공사 · 2 구조물공 · 3 운반공 · 4 자재대 · 관급자재대(제외금액)) · 공구 안 나눔
|
||||
* 항목 = 서버가 B08 수량으로 짠 줄 + 대비표 「별도」(4 자재대) · 「관급」(관급자재대) 자재 + 조달수수료
|
||||
* 금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 조달수수료 = ROUNDDOWN(순자재대 × 율 ÷ 100, 0)
|
||||
* 제목 줄 = 아래 항목 금액 합(빈 줄은 「빈 줄 n」) · 원가계산서로 넘길 합 = sums(빈 줄 있으면 null)
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
computeBase,
|
||||
gap,
|
||||
num,
|
||||
rounddown,
|
||||
type Gap,
|
||||
type Num,
|
||||
type Row,
|
||||
} from "./B09_Estimation_Calc_Base";
|
||||
|
||||
/** 공종 나무 — [번호, 이름, 부모] */
|
||||
export const SECTIONS: Array<[string, string, string | null]> = [
|
||||
["1", "토공사", null],
|
||||
["1.1", "준비공", "1"],
|
||||
["1.2", "절토", "1"],
|
||||
["1.3", "구조물터파기 및 되메우기", "1"],
|
||||
["1.4", "측구파기", "1"],
|
||||
["1.5", "유용성토", "1"],
|
||||
["1.6", "사토 및 다짐공", "1"],
|
||||
["2", "구조물공", null],
|
||||
["2.1", "돌기슭막이공", "2"],
|
||||
["2.2", "배수공", "2"],
|
||||
["2.3", "기타구조물", "2"],
|
||||
["3", "운반공", null],
|
||||
["4", "자재대", null],
|
||||
["G", "관급자재대", null],
|
||||
];
|
||||
const DIRECT = ["1", "2", "3", "4"];
|
||||
const PARTS = ["labor", "material", "expense"];
|
||||
|
||||
function amounts(qty: Num, prices: Row | null): Row {
|
||||
const unit: Record<string, Num> = {};
|
||||
for (const p of PARTS) unit[p] = prices === null ? null : num(prices[p]);
|
||||
const out: Row = {};
|
||||
for (const p of PARTS) out[`price_${p}`] = unit[p];
|
||||
const known = PARTS.every((p) => unit[p] !== null);
|
||||
out.price_sum = known ? unit.labor! + unit.material! + unit.expense! : null;
|
||||
for (const p of PARTS)
|
||||
out[p] = qty !== null && unit[p] !== null ? rounddown(qty * unit[p]!, 0) : null;
|
||||
const filled = PARTS.every((p) => out[p] !== null);
|
||||
out.sum = filled ? out.labor + out.material + out.expense : null;
|
||||
return out;
|
||||
}
|
||||
|
||||
function itemRow(item: Row, prices: Row | null, gaps: Gap[]): Row {
|
||||
const qty = num(item.qty);
|
||||
const row: Row = {
|
||||
kind: "item",
|
||||
key: item.key,
|
||||
section: item.section,
|
||||
name: item.name || "",
|
||||
spec: item.spec || "",
|
||||
unit: item.unit || "",
|
||||
qty,
|
||||
note: item.note || "",
|
||||
ref: item.ref ?? null,
|
||||
...amounts(qty, prices),
|
||||
};
|
||||
if (qty === null) gaps.push(gap("bill", item.key, row.name, "수량 연결 없음"));
|
||||
if (prices === null) gaps.push(gap("bill", item.key, row.name, item.why || "단가 연결 없음"));
|
||||
return row;
|
||||
}
|
||||
|
||||
function materialItems(inputs: Row, compare: Row[]): Row[] {
|
||||
const qtyOf: Row = {};
|
||||
for (const m of inputs.materials || []) if ("qty" in m) qtyOf[m.key] = m.qty;
|
||||
const out: Row[] = [];
|
||||
for (const row of compare) {
|
||||
if (row.supply === null || !(row.key in qtyOf)) continue;
|
||||
out.push({
|
||||
key: `mat:${row.key}`,
|
||||
section: row.supply === "관급" ? "G" : "4",
|
||||
name: row.name,
|
||||
spec: row.spec,
|
||||
unit: row.unit,
|
||||
qty: qtyOf[row.key],
|
||||
note: `자재 ${row.no}`,
|
||||
ref: { tab: "material", key: row.key },
|
||||
_price: row.applied,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function computeBill(inputs: Row, user: Row, base: Row): Row {
|
||||
const gaps: Gap[] = [];
|
||||
const hopyo: Row = inputs.hopyo || {};
|
||||
const items: Row[] = [];
|
||||
for (const item of (inputs.bill || {}).items || []) {
|
||||
const ref: Row = item.ref || {};
|
||||
const prices = ref.key ? (hopyo[ref.key] ?? null) : null;
|
||||
items.push(itemRow(item, prices, gaps));
|
||||
}
|
||||
for (const item of materialItems(inputs, base.compare)) {
|
||||
const price: Num = item._price;
|
||||
const prices = price === null ? null : { labor: 0, material: price, expense: 0 };
|
||||
items.push(itemRow(item, prices, gaps));
|
||||
}
|
||||
const owner = items.filter((row) => row.section === "G");
|
||||
let net = 0;
|
||||
for (const row of owner) net += row.material || 0;
|
||||
const pct = num((user.estimate || {}).procurement_fee_pct);
|
||||
const fee = pct !== null ? rounddown((net * pct) / 100, 0) : null;
|
||||
const feeRow: Row = {
|
||||
kind: "item",
|
||||
key: "fee",
|
||||
section: "G",
|
||||
name: "조달수수료",
|
||||
spec: "관급 재료비 × 율",
|
||||
unit: "%",
|
||||
qty: pct,
|
||||
note: "관급 순자재대 밑수",
|
||||
ref: null,
|
||||
price_labor: 0,
|
||||
price_material: net,
|
||||
price_expense: 0,
|
||||
price_sum: net,
|
||||
labor: pct !== null ? 0 : null,
|
||||
material: fee,
|
||||
expense: pct !== null ? 0 : null,
|
||||
sum: fee,
|
||||
};
|
||||
if (owner.length) {
|
||||
items.push(feeRow);
|
||||
if (pct === null) gaps.push(gap("bill", "fee", "조달수수료", "조달수수료율 입력 없음"));
|
||||
}
|
||||
const sums: Record<string, Row> = {};
|
||||
for (const [code] of SECTIONS)
|
||||
sums[code] = { sum: 0, labor: 0, material: 0, expense: 0, blank: 0 };
|
||||
for (const row of items) {
|
||||
const code = row.section;
|
||||
const chain = [code];
|
||||
const parent = SECTIONS.find(([c]) => c === code)![2];
|
||||
if (parent) chain.push(parent);
|
||||
for (const c of chain) {
|
||||
for (const p of ["sum", ...PARTS]) sums[c][p] += row[p] || 0;
|
||||
if (row.sum === null) sums[c].blank += 1;
|
||||
}
|
||||
}
|
||||
const rows: Row[] = [];
|
||||
for (const [code, name] of SECTIONS) {
|
||||
const s = sums[code];
|
||||
rows.push({
|
||||
kind: "title",
|
||||
key: `sec:${code}`,
|
||||
section: code,
|
||||
name: code !== "G" ? `${code} ${name}` : name,
|
||||
sum: s.sum,
|
||||
labor: s.labor,
|
||||
material: s.material,
|
||||
expense: s.expense,
|
||||
blank: s.blank,
|
||||
note: (code === "G" ? "(제외금액)" : "") + (s.blank ? ` 빈 줄 ${s.blank}` : ""),
|
||||
});
|
||||
rows.push(...items.filter((row) => row.section === code));
|
||||
}
|
||||
const direct: Row = { sum: 0, labor: 0, material: 0, expense: 0 };
|
||||
let blank = 0;
|
||||
for (const code of DIRECT) {
|
||||
for (const p of Object.keys(direct)) direct[p] += sums[code][p];
|
||||
blank += sums[code].blank;
|
||||
}
|
||||
const totals = {
|
||||
direct: { ...direct, blank },
|
||||
owner: { net_material: net, fee, total: net + (fee || 0) },
|
||||
};
|
||||
// 원가계산서로 넘김(145-4 이음 꼴) — 빈 줄이 있으면 모르는 값 = null
|
||||
const known = blank === 0;
|
||||
const ownerKnown = owner.every((row) => row.sum !== null);
|
||||
const sumsOut = {
|
||||
material: known ? direct.material : null,
|
||||
labor: known ? direct.labor : null,
|
||||
expense: known ? direct.expense : null,
|
||||
supply_net: ownerKnown ? net : null,
|
||||
};
|
||||
return { rows, totals, sums: sumsOut, gaps };
|
||||
}
|
||||
|
||||
/** 144 바탕 + 145-3 설계내역서 */
|
||||
export function computeEstimation(inputs: Row, user: Row): Row {
|
||||
const base = computeBase(inputs, user);
|
||||
const bill = computeBill(inputs, user, base);
|
||||
return { ...base, bill, gaps: [...base.gaps, ...bill.gaps] };
|
||||
}
|
||||
@@ -21,14 +21,17 @@ from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Body
|
||||
|
||||
from B08_Quantity.B08_Quantity_Router_Earthwork import earthwork_table
|
||||
from B08_Quantity.B08_Quantity_Router_Hydro import _project_root
|
||||
from B08_Quantity.B08_Quantity_Router_Material import material_sheets
|
||||
from B09_Estimation import B09_Estimation_Prices as prices
|
||||
from B09_Estimation import B09_Estimation_Store as store
|
||||
from B09_Estimation.B09_Estimation_Calc_Base import compute_base
|
||||
from B09_Estimation.B09_Estimation_Bill_Items import bill_items, hopyo_prices
|
||||
from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation
|
||||
from B09_Estimation.B09_Estimation_Calc_Total import compute_total
|
||||
from B09_Estimation.B09_Estimation_Linked import linked_resource_keys
|
||||
from B09_Estimation.B09_Estimation_Rates import rate_candidates
|
||||
from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines
|
||||
|
||||
router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"])
|
||||
|
||||
@@ -36,11 +39,17 @@ router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"])
|
||||
async def _b08(project_id: UUID, notes: list[str]) -> dict[str, Any]:
|
||||
"""B08 수량 원천 — 못 읽으면 빈칸 + 알림(B09 는 B08 를 고치지 않음)."""
|
||||
out: dict[str, Any] = {}
|
||||
try:
|
||||
out["materials"] = await material_sheets(project_id)
|
||||
except Exception as error: # noqa: BLE001 — 원천 하나가 막혀도 나머지 표는 그림
|
||||
notes.append(f"B08 자재 집계표 못 읽음 — {getattr(error, 'detail', error)}")
|
||||
out["materials"] = None
|
||||
for key, name, read in (
|
||||
("materials", "자재 집계표", material_sheets),
|
||||
("earthwork", "토공집계표", earthwork_table),
|
||||
):
|
||||
try:
|
||||
out[key] = await read(project_id)
|
||||
except Exception as error: # noqa: BLE001 — 원천 하나가 막혀도 나머지 표는 그림
|
||||
notes.append(f"B08 {name} 못 읽음 — {getattr(error, 'detail', error)}")
|
||||
out[key] = None
|
||||
# 구조물 집계표 = 자재 집계표가 이미 부른 값(열 · 계 · 조합) — 두 번 안 부름
|
||||
out["summary"] = (out["materials"] or {}).get("source")
|
||||
return out
|
||||
|
||||
|
||||
@@ -54,8 +63,10 @@ async def payload(project_id: UUID, root: Path) -> dict[str, Any]:
|
||||
)
|
||||
book = await asyncio.to_thread(store.read_book, root)
|
||||
inputs = await asyncio.to_thread(prices.build_inputs, book, keys, b08["materials"])
|
||||
inputs["bill"] = bill_items(b08)
|
||||
inputs["hopyo"] = hopyo_prices(root)
|
||||
user = await asyncio.to_thread(store.read_user, root)
|
||||
sheets = compute_base(inputs, user)
|
||||
sheets = compute_estimation(inputs, user)
|
||||
# 145-4 총괄 · 원가계산서 = 설계내역서 비목 합(145-3 `bill.sums`) + 사용자 요율 · 손입력
|
||||
sheets["total"] = compute_total((sheets.get("bill") or {}).get("sums"), user.get("estimate"))
|
||||
return {
|
||||
@@ -65,6 +76,8 @@ async def payload(project_id: UUID, root: Path) -> dict[str, Any]:
|
||||
"sheets": sheets,
|
||||
"rate_candidates": rate_candidates(),
|
||||
"notes": notes,
|
||||
# 집계표 넷 = 서버 단독(145-5) — 짝 셈 결과를 역전개 · 조작 중엔 안 바뀜
|
||||
"summary": compute_summary(sheets, hopyo_lines(root)),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""B09 집계표 넷(PLAN 145-5) — 재료비 · 노무비 · 경비 수량금액집계표 · 중기시간금액집계표
|
||||
(서버 단독).
|
||||
|
||||
역전개 = 설계내역서 항목 수량 × 호표 구성(산근 · 대가 줄 · 겹 호표 끝까지) → 자원마다 총수량.
|
||||
호표 구성 = `hopyo_lines` — 145-1 · 2(산근 · 대가)가 `{호표 키: {"lines": [{kind, key, qty}]}}`
|
||||
(호표 1 단위당 · kind = labor · material · expense · machine · hopyo)로 줌 · 그전엔 빈 묶음
|
||||
4 자재대 · 관급자재대 자재 줄 = 그 자재 그대로(호표 없음)
|
||||
끝수 = 엑셀 자리 그대로 — 총수량 ROUND 2 · 금액 = ROUND(수량 × 단가, 0)(여기만 반올림) ·
|
||||
중기 = 노무 · 재료 · 경비 각 ROUND(시간 × 시간당, 0) · 합계 = 셋 합.
|
||||
단가 = 목록표(노무비 · 재료비 · 경비 · 중기목록표) — 없으면 금액 빈칸(수량은 둠).
|
||||
결과는 GET 때 셈(저장 안 함) · 조작 중엔 다시 안 셈([저장] 뒤 서버 정본).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from B09_Estimation.B09_Estimation_Calc_Base import num, round_half
|
||||
|
||||
#: 겹 호표 깊이 한도(엑셀 최대 대가 → 대가 → 산근 → 중기 = 4) — 돌고 도는 구성 막음
|
||||
MAX_DEPTH = 8
|
||||
KINDS = ("material", "labor", "expense", "machine")
|
||||
|
||||
|
||||
def expand(
|
||||
lines_of: dict[str, Any], key: str, qty: float, out: dict[str, dict[str, float]], depth: int = 0
|
||||
) -> None:
|
||||
"""호표 하나 × 수량 → 자원 총수량에 더함(겹 호표는 끝까지 · 깊이 한도 넘으면 멈춤)."""
|
||||
if depth > MAX_DEPTH:
|
||||
return
|
||||
for line in (lines_of.get(key) or {}).get("lines") or []:
|
||||
q = num(line.get("qty"))
|
||||
if q is None:
|
||||
continue
|
||||
kind = line.get("kind")
|
||||
if kind == "hopyo":
|
||||
expand(lines_of, str(line.get("key")), qty * q, out, depth + 1)
|
||||
elif kind in KINDS:
|
||||
bucket = out.setdefault(kind, {})
|
||||
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]]:
|
||||
"""설계내역서 항목 → 자원 총수량(내역 차례 · 관급 포함)."""
|
||||
out: dict[str, dict[str, float]] = {}
|
||||
for row in (sheets.get("bill") or {}).get("rows") or []:
|
||||
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":
|
||||
bucket = out.setdefault("material", {})
|
||||
bucket[ref["key"]] = bucket.get(ref["key"], 0.0) + qty
|
||||
else:
|
||||
expand(lines_of, str(ref["key"]), qty, out)
|
||||
return out
|
||||
|
||||
|
||||
def _list(rows: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
||||
return {row["key"]: row for row in rows}
|
||||
|
||||
|
||||
def _resource_sheet(used: dict[str, float], listed: dict[str, dict[str, Any]], unit_default: str):
|
||||
rows = []
|
||||
for no, (key, qty) in enumerate(used.items(), 1):
|
||||
src = listed.get(key) or {}
|
||||
q = round_half(qty, 2)
|
||||
price = num(src.get("price"))
|
||||
rows.append(
|
||||
{
|
||||
"no": no,
|
||||
"key": key,
|
||||
"name": src.get("name") or key,
|
||||
"spec": src.get("spec") or "",
|
||||
"unit": src.get("unit") or unit_default,
|
||||
"qty": q,
|
||||
"price": price,
|
||||
"amount": round_half(q * price, 0) if price is not None else None,
|
||||
"note": "" if src else "목록표에 없음",
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _machine_sheet(used: dict[str, float], listed: dict[str, dict[str, Any]]):
|
||||
rows = []
|
||||
for no, (key, qty) in enumerate(used.items(), 1):
|
||||
src = listed.get(key) or {}
|
||||
q = round_half(qty, 2)
|
||||
parts = {}
|
||||
for part in ("labor", "material", "expense"):
|
||||
unit = num(src.get(part))
|
||||
parts[part] = round_half(q * unit, 0) if unit is not None else None
|
||||
known = all(v is not None for v in parts.values())
|
||||
rows.append(
|
||||
{
|
||||
"no": no,
|
||||
"key": key,
|
||||
"name": src.get("name") or key,
|
||||
"spec": src.get("spec") or "",
|
||||
"unit": "시간",
|
||||
"qty": q,
|
||||
**parts,
|
||||
"sum": parts["labor"] + parts["material"] + parts["expense"] if known else None,
|
||||
"note": "" if src else "목록표에 없음",
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def compute_summary(sheets: dict[str, Any], lines_of: dict[str, Any]) -> dict[str, Any]:
|
||||
"""집계표 넷 — `sheets` = 짝 셈 결과(바탕 + 설계내역서)."""
|
||||
used = totals(sheets, lines_of)
|
||||
return {
|
||||
"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"]), ""),
|
||||
"sum_machine": _machine_sheet(used.get("machine", {}), _list(sheets["machine"])),
|
||||
}
|
||||
|
||||
|
||||
def hopyo_lines(project_root: Any) -> dict[str, Any]:
|
||||
"""호표 구성(1 단위당 자원 · 겹 호표) — 145-1 · 2 가 들어오면 채움 · 그전엔 빈 묶음."""
|
||||
return {}
|
||||
@@ -24,7 +24,8 @@ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
|
||||
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
|
||||
import { computeBase, type Row } from "./B09_Estimation_Calc_Base";
|
||||
import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
import { computeEstimation } from "./B09_Estimation_Calc_Bill";
|
||||
import { computeTotal } from "./B09_Estimation_Calc_Total";
|
||||
import {
|
||||
buildCheck,
|
||||
@@ -44,6 +45,13 @@ import {
|
||||
buildMachineBlocks,
|
||||
buildMaterial,
|
||||
} from "./B09_Estimation_UI_Tabs_Base";
|
||||
import { buildBill } from "./B09_Estimation_UI_Tabs_Bill";
|
||||
import {
|
||||
buildSumExpense,
|
||||
buildSumLabor,
|
||||
buildSumMachine,
|
||||
buildSumMaterial,
|
||||
} from "./B09_Estimation_UI_Tabs_Summary";
|
||||
import { buildCost, buildTotal } from "./B09_Estimation_UI_Tabs_Total";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
@@ -59,6 +67,7 @@ const LATER: Record<string, string> = {
|
||||
|
||||
const BUILDERS: Partial<Record<TabId, TabBuild>> = {
|
||||
check: buildCheck,
|
||||
bill: buildBill,
|
||||
basis: buildBasis,
|
||||
machine: buildMachine,
|
||||
machine_blocks: buildMachineBlocks,
|
||||
@@ -68,6 +77,10 @@ const BUILDERS: Partial<Record<TabId, TabBuild>> = {
|
||||
compare: buildCompare,
|
||||
cost: buildCost,
|
||||
total: buildTotal,
|
||||
sum_material: buildSumMaterial,
|
||||
sum_labor: buildSumLabor,
|
||||
sum_expense: buildSumExpense,
|
||||
sum_machine: buildSumMachine,
|
||||
};
|
||||
|
||||
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
@@ -93,7 +106,7 @@ function note(textValue: string): HTMLElement {
|
||||
|
||||
/** 브라우저 셈 — 서버 짝과 같은 식(조작 중) */
|
||||
export function computeSheets(payload: Row, user: Row): Row {
|
||||
const sheets = computeBase(payload.inputs, user);
|
||||
const sheets = computeEstimation(payload.inputs, user);
|
||||
// 145-4 총괄 · 원가계산서 = 설계내역서 비목 합(145-3 `bill.sums`) + 사용자 요율 · 손입력
|
||||
return { ...sheets, total: computeTotal(sheets.bill?.sums, user.estimate) };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Tabs_Bill.ts
|
||||
* B09 설계내역서 탭(PLAN 145-3) — 실무 엑셀 꼴(공종 · 명칭 · 규격 · 수량 · 단위 · 합계 · 노무 · 재료 · 경비 · 비고).
|
||||
*
|
||||
* 값 = `B09_Estimation_Calc_Bill`(조작 중 브라우저 · [저장] 때 서버 정본)
|
||||
* 고칠 칸 = 관급자재대 조달수수료 율(%) · 빈칸 = 「연결 없음」(수량 · 단가 원천 없음)
|
||||
* 줄 누름 = 단가를 낸 탭 줄(자재 → 재료비목록표 · 구조물 → 일위대가목록표)
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DataRow } from "@ui/ui_template_data_table";
|
||||
import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
import {
|
||||
money,
|
||||
parseNumber,
|
||||
qty,
|
||||
text,
|
||||
type TabBuild,
|
||||
type TabId,
|
||||
type TabTable,
|
||||
} from "./B09_Estimation_UI_Tabs";
|
||||
|
||||
export const buildBill: TabBuild = ({ sheets, user, setUser }): TabTable => {
|
||||
const bill: Row = sheets.bill || { rows: [], totals: null };
|
||||
const rows: DataRow[] = bill.rows.map((r: Row) => ({
|
||||
...r,
|
||||
name: r.kind === "title" ? r.name : ` ${r.name}`,
|
||||
}));
|
||||
const direct: Row = bill.totals?.direct || {};
|
||||
const pair = (key: string, label: string) => [
|
||||
money(`price_${key}`, "단가", [label]),
|
||||
money(key, "금액", [label]),
|
||||
];
|
||||
return {
|
||||
columns: [
|
||||
text("name", "공종 · 명칭", 220),
|
||||
text("spec", "규격", 160),
|
||||
qty("qty", "수량", 3),
|
||||
text("unit", "단위", 56),
|
||||
...pair("sum", "합계"),
|
||||
...pair("labor", "노무비"),
|
||||
...pair("material", "재료비"),
|
||||
...pair("expense", "경비"),
|
||||
text("note", "비고", 200),
|
||||
],
|
||||
rows,
|
||||
total: { name: "순공사비 계(관급 제외)", ...direct },
|
||||
frozen: 1,
|
||||
hint:
|
||||
"공종 나무 = 엑셀 꼴 고정 · 금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 4 자재대 = 대비표 「별도」 · 관급자재대 = 「관급」 표시 자재 · 조달수수료 율(%)은 수량 칸에 넣음 · " +
|
||||
`빈 줄 ${direct.blank ?? 0} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음`,
|
||||
editable: (i, key) => key === "qty" && rows[i]?.key === "fee",
|
||||
onEdit: (i, key, value) => {
|
||||
if (key !== "qty" || rows[i]?.key !== "fee") return;
|
||||
const v = parseNumber(value);
|
||||
if (v === undefined) return;
|
||||
setUser({ ...user, estimate: { ...(user.estimate || {}), procurement_fee_pct: v } });
|
||||
},
|
||||
link: (i) => {
|
||||
const ref = rows[i]?.ref as Row | null | undefined;
|
||||
return ref?.key ? { tab: ref.tab as TabId, key: String(ref.key) } : null;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Tabs_Summary.ts
|
||||
* B09 집계표 넷(PLAN 145-5) — 재료비 · 노무비 · 경비 수량금액집계표 · 중기시간금액집계표(실무 엑셀 꼴).
|
||||
*
|
||||
* 값 = 서버 단독 역전개(`B09_Estimation_Summary` · GET 응답 `summary`) — 조작 중엔 안 바뀜 · [저장] 뒤 정본
|
||||
* 끝수 = 총수량 ROUND 2 · 금액 ROUND(수량 × 단가, 0) · 줄 누름 = 단가를 낸 목록표 줄
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DataRow } from "@ui/ui_template_data_table";
|
||||
import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
import {
|
||||
money,
|
||||
qty,
|
||||
text,
|
||||
type TabBuild,
|
||||
type TabId,
|
||||
type TabTable,
|
||||
} from "./B09_Estimation_UI_Tabs";
|
||||
|
||||
const HINT =
|
||||
"총수량 = 설계내역서 수량 × 호표(산근 · 대가) 구성 역전개(서버 · [저장] 뒤 다시 셈) · ROUND 2 · 금액 = ROUND(수량 × 단가, 0) · 줄 누름 = 목록표";
|
||||
|
||||
function resourceTab(id: string, list: TabId, unitPrice: string): 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);
|
||||
return {
|
||||
columns: [
|
||||
qty("no", "호표", 0),
|
||||
text("name", "명칭", 200),
|
||||
text("spec", "규격", 140),
|
||||
qty("qty", "수량", 2),
|
||||
text("unit", "단위", 56),
|
||||
money("price", unitPrice),
|
||||
money("amount", "금액"),
|
||||
text("note", "비고", 120),
|
||||
],
|
||||
rows,
|
||||
total: rows.length ? { name: "계", amount: sum } : null,
|
||||
frozen: 2,
|
||||
hint: HINT,
|
||||
link: (i) => (rows[i] ? { tab: list, key: String(rows[i].key) } : null),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const buildSumMaterial = resourceTab("sum_material", "material", "단가");
|
||||
export const buildSumLabor = resourceTab("sum_labor", "labor", "단가");
|
||||
export const buildSumExpense = resourceTab("sum_expense", "expense", "단가");
|
||||
|
||||
export const buildSumMachine: TabBuild = ({ payload }): TabTable => {
|
||||
const rows: DataRow[] = ((payload.summary || {}).sum_machine || []).map((r: Row) => ({ ...r }));
|
||||
const add = (key: string) =>
|
||||
rows.reduce((acc, r) => acc + (typeof r[key] === "number" ? (r[key] as number) : 0), 0);
|
||||
return {
|
||||
columns: [
|
||||
qty("no", "호표", 0),
|
||||
text("name", "명칭", 200),
|
||||
text("spec", "규격", 120),
|
||||
qty("qty", "수량", 2),
|
||||
text("unit", "단위", 56),
|
||||
money("sum", "합계"),
|
||||
money("labor", "노무비"),
|
||||
money("material", "재료비"),
|
||||
money("expense", "경비"),
|
||||
text("note", "비고", 120),
|
||||
],
|
||||
rows,
|
||||
total: rows.length
|
||||
? {
|
||||
name: "계",
|
||||
sum: add("sum"),
|
||||
labor: add("labor"),
|
||||
material: add("material"),
|
||||
expense: add("expense"),
|
||||
}
|
||||
: null,
|
||||
frozen: 2,
|
||||
hint: `${HINT} · 비목마다 ROUND(시간 × 시간당, 0)`,
|
||||
link: (i) => (rows[i] ? { tab: "machine", key: String(rows[i].key) } : null),
|
||||
};
|
||||
};
|
||||
@@ -106,7 +106,7 @@ def test_연결_없으면_빈_표와_연결_없음(tmp_path, monkeypatch):
|
||||
assert sheets["labor"] == [] and sheets["expense"] == [] and sheets["machine"] == []
|
||||
assert [row["key"] for row in sheets["compare"]] == ["b08:sand", "b08:cement"] # 수량 있는 줄만
|
||||
assert all(row["applied"] is None for row in sheets["compare"]) # 출처 없음 = 빈칸(0 아님)
|
||||
assert [(g["tab"], g["key"]) for g in sheets["gaps"]] == [
|
||||
assert [(g["tab"], g["key"]) for g in sheets["gaps"] if g["tab"] == "compare"] == [
|
||||
("compare", "b08:sand"),
|
||||
("compare", "b08:cement"),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
"""145-3 B09 설계내역서 — 엑셀 검산 대조 · 항목 짜기 · 원가계산서 이음 꼴 ·
|
||||
거울(`Calc_Bill.py` ↔ `.ts`).
|
||||
|
||||
견본 = 기번3 공통 엑셀 설계내역서:
|
||||
9 · 10줄 규준틀(대가 5 · 8) · 14줄 절토(토사)(산근 13) — 같은 호표 단가면 같은 금액
|
||||
246~253줄 관급자재대 — 순자재대 69,474,220 · 조달수수료 0.54 % = 375,160 · 계 69,849,380
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
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_Bill_Items import bill_items # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: E402
|
||||
|
||||
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
|
||||
|
||||
#: 관급 자재(엑셀 246~252 · 재료비목록표 단가) — [키, 품명, 규격, 단위, 수량, 단가]
|
||||
OWNER = [
|
||||
("b08:remicon_25_18_80", "레미콘", "25-18-80", "M3", 32, 102970),
|
||||
("b08:pipe_800", "파형강관", "Φ800m/m, 2.7t", "M", 212, 157000),
|
||||
("b08:pipe_1000", "파형강관", "Φ1000m/m, 2.7t", "M", 132, 196270),
|
||||
("b08:pipe_1200", "파형강관", "Φ1200m/m, 3.2t", "M", 22, 273370),
|
||||
("b08:cp_800", "파형강관이음관", "Φ800m/m", "개", 22, 21200),
|
||||
("b08:cp_1000", "파형강관이음관", "Φ1000m/m", "개", 12, 32200),
|
||||
("b08:cp_1200", "파형강관이음관", "Φ1200m/m", "개", 3, 40200),
|
||||
]
|
||||
MATERIALS = [
|
||||
{"key": k, "name": n, "spec": s, "unit": u, "origin": "B08", "prices": {"관급": p}, "qty": q}
|
||||
for k, n, s, u, q, p in OWNER
|
||||
] + [
|
||||
{"key": "b08:sand", "name": "골재", "spec": "모래", "unit": "㎥", "origin": "B08",
|
||||
"prices": {"물가정보": 30000}, "qty": 16},
|
||||
{"key": "b08:rubble", "name": "골재", "spec": "막자갈", "unit": "㎥", "origin": "B08",
|
||||
"prices": {}, "qty": 5},
|
||||
] # fmt: skip
|
||||
ITEMS = [
|
||||
{"key": "earth:1", "section": "1.1", "name": "규준틀설치 종단규준틀", "unit": "개소", "qty": 14,
|
||||
"ref": {"tab": "dg_list", "key": "B00194"}},
|
||||
{"key": "earth:2", "section": "1.1", "name": "규준틀설치 횡단규준틀", "unit": "개소", "qty": 28,
|
||||
"ref": {"tab": "dg_list", "key": "B00467"}},
|
||||
{"key": "earth:4", "section": "1.2", "name": "절토(토사)", "unit": "㎥", "qty": 5675,
|
||||
"ref": {"tab": "sg_list", "key": "D01341"}},
|
||||
{"key": "earth:5", "section": "1.2", "name": "절토(연암)", "unit": "㎥", "qty": 972,
|
||||
"ref": None,
|
||||
"why": "조합 밖 토공 산근 — 연결 없음"},
|
||||
] # fmt: skip
|
||||
HOPYO = {
|
||||
"B00194": {"labor": 21936, "material": 3885, "expense": 0}, # 일위대가목록표 F8~H8
|
||||
"B00467": {"labor": 33032, "material": 6942, "expense": 0},
|
||||
"D01341": {"labor": 1116, "material": 360, "expense": 463}, # 단가산출근거목록표 16줄
|
||||
}
|
||||
INPUTS = {"materials": MATERIALS, "bill": {"items": ITEMS}, "hopyo": HOPYO}
|
||||
USER = {
|
||||
"materials": {**{k: {"supply": "관급"} for k, *_ in OWNER}, "b08:sand": {"supply": "별도"},
|
||||
"b08:rubble": {"supply": "별도"}},
|
||||
"estimate": {"procurement_fee_pct": 0.54},
|
||||
} # fmt: skip
|
||||
|
||||
|
||||
def _run_ts(inputs, user):
|
||||
files = ["B09_Estimation_Calc_Base.ts", "B09_Estimation_Calc_Bill.ts"]
|
||||
source = "".join(
|
||||
IMPORTS.sub("", (ROOT / "B09_Estimation" / f).read_text(encoding="utf-8")) for f in files
|
||||
)
|
||||
body = (
|
||||
f"\nconst i = {json.dumps(inputs, ensure_ascii=False)};"
|
||||
f"\nconst u = {json.dumps(user, ensure_ascii=False)};"
|
||||
"\nconsole.log(JSON.stringify(computeEstimation(i, u)));"
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", source + body],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def _rows(sheets):
|
||||
return {row["key"]: row for row in sheets["bill"]["rows"]}
|
||||
|
||||
|
||||
def test_호표_단가_줄_엑셀_금액():
|
||||
rows = _rows(compute_estimation(INPUTS, USER))
|
||||
row = rows["earth:1"]
|
||||
assert (row["price_sum"], row["labor"], row["material"], row["expense"], row["sum"]) == (
|
||||
25821, 307104, 54390, 0, 361494,
|
||||
) # fmt: skip # 설계내역서 F9 · I9 · K9 · M9 · G9
|
||||
assert rows["earth:2"]["sum"] == 1119272 # G10
|
||||
row = rows["earth:4"]
|
||||
assert (row["labor"], row["material"], row["expense"], row["sum"]) == (
|
||||
6333300, 2043000, 2627525, 11003825,
|
||||
) # fmt: skip # 설계내역서 I14 · K14 · M14 · G14
|
||||
assert rows["earth:5"]["sum"] is None and rows["earth:5"]["qty"] == 972 # 단가 연결 없음
|
||||
|
||||
|
||||
def test_관급자재대_조달수수료_엑셀():
|
||||
sheets = compute_estimation(INPUTS, USER)
|
||||
rows = _rows(sheets)
|
||||
assert rows["mat:b08:pipe_800"]["material"] == 33284000 # K247
|
||||
assert rows["mat:b08:remicon_25_18_80"]["section"] == "G"
|
||||
fee = rows["fee"]
|
||||
assert (fee["qty"], fee["price_material"], fee["material"]) == (0.54, 69474220, 375160) # 253줄
|
||||
assert rows["sec:G"]["material"] == 69849380 # K245 관급자재대
|
||||
assert "(제외금액)" in rows["sec:G"]["note"]
|
||||
owner = sheets["bill"]["totals"]["owner"]
|
||||
assert owner == {"net_material": 69474220, "fee": 375160, "total": 69849380}
|
||||
# 「별도」 = 4 자재대 · 단가 없는 별도 자재 = 빈 줄
|
||||
assert rows["mat:b08:sand"]["section"] == "4" and rows["mat:b08:sand"]["material"] == 480000
|
||||
assert rows["mat:b08:rubble"]["sum"] is None and rows["sec:4"]["blank"] == 1
|
||||
|
||||
|
||||
def test_제목_합_빈_줄과_원가계산서_이음():
|
||||
sheets = compute_estimation(INPUTS, USER)
|
||||
rows = _rows(sheets)
|
||||
keys = [row["key"] for row in sheets["bill"]["rows"] if row["kind"] == "title"]
|
||||
assert keys[:3] == ["sec:1", "sec:1.1", "sec:1.2"] and keys[-1] == "sec:G"
|
||||
assert rows["sec:1.1"]["sum"] == 361494 + 1119272 and rows["sec:1.1"]["blank"] == 0
|
||||
assert rows["sec:1"]["sum"] == 361494 + 1119272 + 11003825 # 빈 줄은 0 이 아니라 빈 줄 수
|
||||
assert rows["sec:1"]["blank"] == 1 and "빈 줄 1" in rows["sec:1"]["note"]
|
||||
# 145-4 이음 = {material, labor, expense, supply_net} · 빈 줄 있으면 None
|
||||
assert sheets["bill"]["sums"] == {
|
||||
"material": None,
|
||||
"labor": None,
|
||||
"expense": None,
|
||||
"supply_net": 69474220,
|
||||
}
|
||||
full = {**INPUTS, "bill": {"items": ITEMS[:3]}, "materials": MATERIALS[:-1]}
|
||||
sums = compute_estimation(full, USER)["bill"]["sums"]
|
||||
assert sums["labor"] == 307104 + 924896 + 6333300 # I9 + I10 + I14
|
||||
assert sums["material"] == 54390 + 194376 + 2043000 + 480000
|
||||
assert sums["expense"] == 2627525
|
||||
|
||||
|
||||
def test_조달수수료_율_빈칸이면_빈칸():
|
||||
user = {**USER, "estimate": {}}
|
||||
sheets = compute_estimation(INPUTS, user)
|
||||
fee = _rows(sheets)["fee"]
|
||||
assert fee["material"] is None and fee["qty"] is None
|
||||
assert ("bill", "fee") in {(g["tab"], g["key"]) for g in sheets["gaps"]}
|
||||
assert sheets["bill"]["totals"]["owner"]["total"] == 69474220
|
||||
|
||||
|
||||
def test_항목_짜기_B08_수량():
|
||||
earthwork = {"summary": [
|
||||
{"group": "흙깍기", "kind": "토사", "spec": "기계(굴삭기)", "unit": "㎥", "value": 5675},
|
||||
{"group": "흙깍기", "kind": "연암", "spec": "굴삭기 + 브레카", "unit": "㎥", "value": 972},
|
||||
{"group": "토사운반", "kind": "", "spec": "BH+DT+BH", "unit": "㎥", "value": 120},
|
||||
{"group": "토사운반", "kind": "", "spec": "19 TON B/D", "unit": "㎥", "value": 80},
|
||||
{"group": "구조물터파기", "kind": "토사", "spec": "기계(굴삭기)", "unit": "㎥",
|
||||
"value": None},
|
||||
]} # fmt: skip
|
||||
summary = {
|
||||
"columns": [
|
||||
{"id": "pipe:revet|돌쌓기(찰)|2.0", "group": ["흙막이", "찰쌓기", "H=2.0"],
|
||||
"label": "m", "combos": ["기슭막이 찰"]},
|
||||
{"id": "pipe:len|800", "group": ["관 공", "Φ800m/m", "관매설"], "label": "m"},
|
||||
{"id": "pipe:cp|800", "group": ["관 공", "Φ800m/m", "커플링"], "label": "ea"},
|
||||
{"id": "pipe:chute|찰붙임", "group": ["돌붙임", "찰붙임"], "label": "㎡"},
|
||||
],
|
||||
"total": {"pipe:revet|돌쌓기(찰)|2.0": 20, "pipe:len|800": 84, "pipe:cp|800": 9},
|
||||
} # fmt: skip
|
||||
materials = {"material": {"rows": [
|
||||
{"key": "sand", "amount": 16}, {"key": "rebar_d13", "amount": 0.5},
|
||||
{"key": "rebar_d16", "amount": 0.25}, {"key": "gravel", "amount": None},
|
||||
]}} # fmt: skip
|
||||
items = {i["key"]: i for i in bill_items({"earthwork": earthwork, "summary": summary,
|
||||
"materials": materials})["items"]} # fmt: skip
|
||||
assert items["earth:4"]["qty"] == 5675 and items["earth:4"]["name"] == "절토(토사)"
|
||||
assert items["earth:5"]["qty"] == 972 and items["earth:6"]["qty"] is None # 경암 줄 없음
|
||||
assert items["earth:13"]["qty"] == 80 and items["earth:14"]["qty"] == 120 # 도자 · 덤프
|
||||
assert items["earth:7"]["qty"] is None # 구조물터파기 = B08 도 빈칸
|
||||
assert items["earth:1"]["why"].startswith("준비공") and items["earth:4"]["ref"] is None
|
||||
st = {k: v for k, v in items.items() if k.startswith("st:")}
|
||||
assert [(v["section"], v["qty"]) for v in st.values()] == [
|
||||
("2.1", 20),
|
||||
("2.2", 84),
|
||||
("2.3", None),
|
||||
]
|
||||
assert "st:pipe:cp|800" not in items # 커플링 = 관급 자재
|
||||
assert items["st:pipe:revet|돌쌓기(찰)|2.0"]["ref"] == {
|
||||
"tab": "dg_list",
|
||||
"key": "pipe:revet|돌쌓기(찰)|2.0",
|
||||
}
|
||||
haul = {v["name"]: v["qty"] for k, v in items.items() if k.startswith("haul:")}
|
||||
assert haul == {"중기운반": None, "기계운반": None, "모래운반": 16, "자갈운반": None,
|
||||
"시멘트운반": None, "철근운반": 0.75} # fmt: skip
|
||||
|
||||
|
||||
def test_거울_TS_가_같은_값():
|
||||
for user in (USER, {**USER, "estimate": {}}, {}):
|
||||
assert _run_ts(INPUTS, user) == compute_estimation(INPUTS, user)
|
||||
@@ -0,0 +1,97 @@
|
||||
"""145-5 B09 집계표 넷 — 역전개(겹 호표) · 엑셀 끝수 · 검산 대조(서버 단독 · 거울 없음).
|
||||
|
||||
견본 = 기번3 공통 엑셀 집계표:
|
||||
중기시간금액집계표 4줄 불도우저19ton 42.95 시간 → 2,392,315 · 1,584,340 · 1,435,045 = 5,411,700
|
||||
노무비수량금액집계표 4줄 건축목공 8.29 인 × 268,058 = 2,222,201
|
||||
재료비수량금액집계표 각재 0.56 × 500,661 = 280,370 · 못 3.54 × 1,503 = 5,321
|
||||
경비수량금액집계표 구역화물자동차 11.03 회 × 61,500 = 678,345
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
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 import B09_Estimation_Router as router_mod # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Summary import compute_summary # noqa: E402
|
||||
|
||||
SHEETS = {
|
||||
"labor": [{"key": "L1", "name": "건축목공", "spec": "", "unit": "인", "price": 268058}],
|
||||
"material": [
|
||||
{"key": "M3", "name": "각재", "spec": "외송", "unit": "M3", "price": 500661},
|
||||
{"key": "M4", "name": "못", "spec": "N 50", "unit": "KG", "price": 1503},
|
||||
],
|
||||
"expense": [
|
||||
{"key": "E16", "name": "구역화물자동차", "spec": "5톤", "unit": "회", "price": 61500}
|
||||
],
|
||||
"machine": [
|
||||
{
|
||||
"key": "X1",
|
||||
"name": "불도우저19ton",
|
||||
"spec": "(토사)",
|
||||
"unit": "시간",
|
||||
"sum": 126000,
|
||||
"labor": 55700,
|
||||
"material": 36888,
|
||||
"expense": 33412,
|
||||
},
|
||||
], # fmt: skip
|
||||
"bill": {
|
||||
"rows": [
|
||||
{"kind": "title", "key": "sec:1", "qty": None},
|
||||
{"kind": "item", "key": "a", "qty": 10, "ref": {"tab": "sg_list", "key": "A"}},
|
||||
{"kind": "item", "key": "m", "qty": 0.56, "ref": {"tab": "material", "key": "M3"}},
|
||||
{"kind": "item", "key": "none", "qty": None, "ref": {"tab": "sg_list", "key": "A"}},
|
||||
{"kind": "item", "key": "loop", "qty": 1, "ref": {"tab": "dg_list", "key": "C"}},
|
||||
]
|
||||
},
|
||||
}
|
||||
LINES = {
|
||||
"A": {"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},
|
||||
]},
|
||||
"B": {"lines": [{"kind": "labor", "key": "L1", "qty": 1.658},
|
||||
{"kind": "labor", "key": "L9", "qty": 1}]},
|
||||
"C": {"lines": [{"kind": "hopyo", "key": "C", "qty": 1}]}, # 스스로 부름 — 깊이 한도에서 멈춤
|
||||
} # fmt: skip
|
||||
|
||||
|
||||
def test_역전개_엑셀_금액():
|
||||
s = compute_summary(SHEETS, LINES)
|
||||
machine = s["sum_machine"][0]
|
||||
assert (machine["qty"], machine["labor"], machine["material"], machine["expense"]) == (
|
||||
42.95, 2392315, 1584340, 1435045,
|
||||
) # fmt: skip
|
||||
assert machine["sum"] == 5411700
|
||||
labor = {r["key"]: r for r in s["sum_labor"]}
|
||||
assert (labor["L1"]["qty"], labor["L1"]["amount"]) == (
|
||||
8.29,
|
||||
2222201,
|
||||
) # 겹 호표 10 × 0.5 × 1.658
|
||||
assert labor["L9"]["amount"] is None and labor["L9"]["note"] == "목록표에 없음"
|
||||
material = {r["key"]: (r["qty"], r["amount"]) for r in s["sum_material"]}
|
||||
assert material == {"M4": (3.54, 5321), "M3": (0.56, 280370)} # 내역 자재 줄 그대로
|
||||
assert [(r["qty"], r["amount"]) for r in s["sum_expense"]] == [(11.03, 678345)]
|
||||
|
||||
|
||||
def test_호표_구성_없으면_자재_줄만():
|
||||
s = compute_summary(SHEETS, {})
|
||||
assert s["sum_labor"] == [] and s["sum_machine"] == [] and s["sum_expense"] == []
|
||||
assert [r["key"] for r in s["sum_material"]] == ["M3"]
|
||||
|
||||
|
||||
def test_GET_응답에_집계표(tmp_path, monkeypatch):
|
||||
async def fake_materials(_project_id):
|
||||
return {"material": {"rows": [{"key": "sand", "group": "골재", "spec": "모래",
|
||||
"unit": "㎥", "amount": 16}]}} # fmt: skip
|
||||
|
||||
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 body["summary"]["sum_material"] == [] # 「별도」 · 「관급」 표시 전엔 내역 자재 줄 없음
|
||||
Reference in New Issue
Block a user