feat(B09): 총괄설계내역서 · 공사원가계산서 탭 · 제비율 셈 파이썬 · TS 짝(145-4)
- compute_total(sums, estimate) · computeTotal — 설계내역서 비목 합(bill.sums) + 사용자 요율 · 손입력 - 끝수 엑셀 그대로(줄마다 ROUNDDOWN 0 · 관급 ROUNDUP −3 · 이윤 − 보정 · 부가세 + 조정) · 안전관리비 A/B 작은 쪽 - 요율 빈칸 시작 · 빈칸이면 그 줄 · 소계 빈칸 · 후보 = M01 요율_조달청제비율 글(확정 아님) - 엑셀에 없는 현행 비목 다섯 = 줄만 · 원가계산서 = 총괄 거울 + 구성비 · 줄 누름 = 총괄 그 줄 - 엑셀 검산(순공사원가 868,874,829 · 총공사비 1,201,879,000) · 거울 시험 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
"""B09 총괄설계내역서 · 공사원가계산서 셈(PLAN 145-4) — 화면 없음 · 저장 안 함 · 파일 · M01 안 읽음.
|
||||
|
||||
⚠ 짝 = `B09_Estimation_Calc_Total.ts`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 ·
|
||||
거울 시험 `resources/tester/test_145_4_b09_total_calc.py`.
|
||||
조작 중은 화면 짝이 바로 셈 · [저장] 때 서버가 이 파일로 정본을 다시 셈.
|
||||
|
||||
입력
|
||||
sums = 설계내역서 비목 합(145-3 `sheets.bill.sums`)
|
||||
material 직접재료비 · labor 직접노무비 · expense 산출경비(관급 제외 순공사비계) ·
|
||||
supply_net 관급 순자재대(조달수수료 전)
|
||||
estimate = 사용자 값(`user.estimate`) — rates {줄 키: %} · safety_base 기초액 ·
|
||||
profit_adjust 이윤 보정 · vat_adjust 부가세 조정 · indirect_material 간접재료비 ·
|
||||
scrap 작업설 · procurement_fee_pct 조달수수료율
|
||||
식 · 끝수 = 엑셀 총괄 49~67 그대로(줄마다 ROUNDDOWN 0 · 밑수 = 앞 줄 버린 값 · 관급 ROUNDUP −3) ·
|
||||
산업안전보건관리비 = A(관급 순자재대 ÷ 1.1 포함) · B(관급 빼고 × 1.2) 중 작은 쪽 ·
|
||||
이윤 = ROUNDDOWN((다 − 재료비) × 율, 0) − 보정 · 부가세 = ROUNDDOWN(공급가액 × 율, 0) + 조정.
|
||||
빈칸 규칙 = 요율 · 비목 합이 빈칸이면 그 줄 빈칸(0 을 채우지 않음) · 그 줄을 쓰는 소계도 빈칸 ·
|
||||
손입력(기초액 · 보정 · 조정 · 간접재료비 · 작업설) 빈칸 = 0 · 엑셀에 없는 현행 비목 = 줄만(늘 빈칸).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from B09_Estimation.B09_Estimation_Calc_Base import num, rounddown, roundup, total
|
||||
|
||||
#: 제비율 줄 (키, 이름, M01 요율 후보 키, 산식 글) — 엑셀 총괄 49~58 차례 · 나.소계에 듦
|
||||
RATE_LINES: tuple[tuple[str, str, str, str], ...] = (
|
||||
("indirect_labor", "간접노무비", "RT000008", "직접노무비 × 율"),
|
||||
("accident", "산재보험료", "RT000001", "(직접노무비 + 간접노무비) × 율"),
|
||||
("employment", "고용보험료", "RT000002", "(직접노무비 + 간접노무비) × 율"),
|
||||
("health", "건강보험료", "RT000003", "직접노무비 × 율"),
|
||||
("care", "노인장기요양보험료", "RT000005", "건강보험료 × 율"),
|
||||
("pension", "연금보험료", "RT000004", "직접노무비 × 율"),
|
||||
("safety", "산업안전보건관리비", "RT000006", "A · B 중 작은 쪽 + 기초액"),
|
||||
("other", "기타경비", "RT000009", "(직접노무비 + 간접노무비 + 재료비) × 율"),
|
||||
("environment", "환경보전비", "RT000010", "순공사비 × 율"),
|
||||
("machine", "건설기계대여금지급보증", "RT000015", "순공사비 × 율"),
|
||||
)
|
||||
#: 엑셀에 없는 현행 비목 — 줄만 두고 빈칸(밑수 · 고르는 값 미정 · 나.소계에 안 듦)
|
||||
CURRENT_ONLY: tuple[tuple[str, str, str], ...] = (
|
||||
("retirement", "퇴직공제부금비", "RT000011"),
|
||||
("wage_claim", "임금채권보장분담금", "RT000012"),
|
||||
("asbestos", "석면피해구제분담금", "RT000013"),
|
||||
("subcontract", "하도급대금지급보증수수료", "RT000014"),
|
||||
("performance", "공사이행보증수수료", "RT000016"),
|
||||
)
|
||||
#: 소계 아래 줄 (키, 이름, 후보 키, 산식 글)
|
||||
TAIL_LINES: tuple[tuple[str, str, str, str], ...] = (
|
||||
("overhead", "일반관리비", "RT000017", "나.소계 × 율"),
|
||||
("profit", "이윤", "RT000018", "(다.소계 − 재료비) × 율 − 보정"),
|
||||
("vat", "부가가치세", "RT000019", "공급가액 × 율 + 조정"),
|
||||
)
|
||||
_SAFETY_VAT_DIVISOR = 1.1 # 관급 순자재대가 부가세 포함 — 1.1 로 나눠 대상액에
|
||||
_SAFETY_B = 1.2 # B안 = (관급 빼고) × 1.2
|
||||
|
||||
|
||||
def _pct(base: float | None, rate: float | None) -> float | None:
|
||||
"""ROUNDDOWN(밑수 × 율 ÷ 100, 0) — 둘 중 빈칸이면 빈칸."""
|
||||
return None if base is None or rate is None else rounddown(base * rate / 100)
|
||||
|
||||
|
||||
def procurement_fee(net: float | None, pct: float | None) -> float | None:
|
||||
"""조달수수료 = ROUNDDOWN(관급 순자재대 × 율 ÷ 100, 0)(엑셀 설계내역서 253 줄)."""
|
||||
return _pct(net, pct)
|
||||
|
||||
|
||||
def compute_total(sums: dict[str, Any] | None, estimate: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""총괄 제비율 줄 · 소계 · 관급 · 원가계산서 줄 · 구성비."""
|
||||
sums = sums if isinstance(sums, dict) else {}
|
||||
est = estimate if isinstance(estimate, dict) else {}
|
||||
rates_in = est.get("rates") if isinstance(est.get("rates"), dict) else {}
|
||||
rate = {key: num(rates_in.get(key)) for key, *_ in (*RATE_LINES, *TAIL_LINES)}
|
||||
|
||||
def hand(key: str) -> float:
|
||||
return num(est.get(key)) or 0.0
|
||||
|
||||
material, labor = num(sums.get("material")), num(sums.get("labor"))
|
||||
expense, supply_net = num(sums.get("expense")), num(sums.get("supply_net"))
|
||||
direct = total([material, labor, expense]) # 가. 순공사비계
|
||||
|
||||
line: dict[str, float | None] = {}
|
||||
line["indirect_labor"] = _pct(labor, rate["indirect_labor"])
|
||||
total_labor = total([labor, line["indirect_labor"]])
|
||||
line["accident"] = _pct(total_labor, rate["accident"])
|
||||
line["employment"] = _pct(total_labor, rate["employment"])
|
||||
line["health"] = _pct(labor, rate["health"])
|
||||
line["care"] = _pct(line["health"], rate["care"])
|
||||
line["pension"] = _pct(labor, rate["pension"])
|
||||
# 산업안전보건관리비 — A(관급 순자재대 ÷ 1.1 포함) · B(× 1.2) 중 작은 쪽(관급 없으면 A)
|
||||
safety_a = safety_b = None
|
||||
labor_material = total([labor, material])
|
||||
if labor_material is not None and rate["safety"] is not None:
|
||||
base = hand("safety_base")
|
||||
target = labor_material + (supply_net or 0.0) / _SAFETY_VAT_DIVISOR
|
||||
safety_a = rounddown(target * rate["safety"] / 100 + base)
|
||||
safety_b = rounddown((labor_material * rate["safety"] / 100 + base) * _SAFETY_B)
|
||||
line["safety"] = None if safety_a is None else min(safety_a, safety_b)
|
||||
line["other"] = _pct(total([labor, line["indirect_labor"], material]), rate["other"])
|
||||
line["environment"] = _pct(direct, rate["environment"])
|
||||
line["machine"] = _pct(direct, rate["machine"])
|
||||
net_cost = total([direct, *(line[key] for key, *_ in RATE_LINES)]) # 나. 순공사원가
|
||||
line["overhead"] = _pct(net_cost, rate["overhead"])
|
||||
da = total([net_cost, line["overhead"]]) # 다
|
||||
profit_raw = _pct(None if da is None or material is None else da - material, rate["profit"])
|
||||
line["profit"] = None if profit_raw is None else profit_raw - hand("profit_adjust")
|
||||
supply_price = total([da, line["profit"]]) # 라. 공급가액
|
||||
vat = _pct(supply_price, rate["vat"])
|
||||
line["vat"] = None if vat is None else vat + hand("vat_adjust")
|
||||
contract = total([supply_price, line["vat"]]) # 마. 도급공사비
|
||||
# 관급자재대 = ROUNDUP(순자재대 + 조달수수료, −3) · 관급 0 이면 0
|
||||
fee = (
|
||||
0.0 if supply_net == 0 else procurement_fee(supply_net, num(est.get("procurement_fee_pct")))
|
||||
)
|
||||
supply_raw = total([supply_net, fee])
|
||||
line["supply"] = None if supply_raw is None else roundup(supply_raw, -3)
|
||||
grand = total([contract, line["supply"]]) # 바. 총공사비
|
||||
|
||||
indirect_material, scrap = hand("indirect_material"), hand("scrap")
|
||||
cost = {
|
||||
"direct_material": None if material is None else material - indirect_material + scrap,
|
||||
"indirect_material": indirect_material,
|
||||
"scrap": scrap,
|
||||
"material": material,
|
||||
"direct_labor": labor,
|
||||
"indirect_labor": line["indirect_labor"],
|
||||
"labor": total_labor,
|
||||
"expense": expense,
|
||||
**{key: line[key] for key in ("accident", "employment", "health", "care", "pension")},
|
||||
**{key: line[key] for key in ("safety", "other", "environment", "machine")},
|
||||
"expense_sum": total(
|
||||
[expense, *(line[key] for key, *_ in RATE_LINES if key != "indirect_labor")]
|
||||
),
|
||||
"overhead": line["overhead"],
|
||||
"profit": line["profit"],
|
||||
"supply_price": supply_price,
|
||||
"vat": line["vat"],
|
||||
"contract": contract,
|
||||
"supply": line["supply"],
|
||||
"grand": grand,
|
||||
}
|
||||
share = {
|
||||
key: (None if value is None or not grand else value / grand * 100)
|
||||
for key, value in cost.items()
|
||||
}
|
||||
return {
|
||||
"sums": {"material": material, "labor": labor, "expense": expense, "direct": direct},
|
||||
"lines": line,
|
||||
"subtotals": {
|
||||
"net_cost": net_cost,
|
||||
"da": da,
|
||||
"supply_price": supply_price,
|
||||
"contract": contract,
|
||||
"grand": grand,
|
||||
},
|
||||
"safety": {"a": safety_a, "b": safety_b},
|
||||
"profit_raw": profit_raw,
|
||||
"supply": {"net": supply_net, "fee": fee, "raw": supply_raw},
|
||||
"cost": cost,
|
||||
"share": share,
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_Calc_Total.ts
|
||||
* B09 총괄설계내역서 · 공사원가계산서 셈(PLAN 145-4) — 화면 짝.
|
||||
*
|
||||
* ⚠ 짝 = `B09_Estimation_Calc_Total.py`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 ·
|
||||
* 거울 시험 `resources/tester/test_145_4_b09_total_calc.py`.
|
||||
* 조작 중은 이 파일이 바로 셈 · [저장] 때 서버가 파이썬 짝으로 정본을 다시 셈.
|
||||
*
|
||||
* 입력 sums = 설계내역서 비목 합(145-3 `sheets.bill.sums`) · estimate = 사용자 값(`user.estimate`)
|
||||
* 식 · 끝수 = 엑셀 총괄 49~67 그대로 · 빈칸 규칙 = 파이썬 짝 머리 글 그대로.
|
||||
* ========================================================================== */
|
||||
|
||||
import { num, rounddown, roundup, total, type Row } from "./B09_Estimation_Calc_Base";
|
||||
|
||||
type Num = number | null;
|
||||
|
||||
/** 제비율 줄 [키, 이름, M01 요율 후보 키, 산식 글] — 엑셀 총괄 49~58 차례 · 나.소계에 듦 */
|
||||
export const RATE_LINES: Array<[string, string, string, string]> = [
|
||||
["indirect_labor", "간접노무비", "RT000008", "직접노무비 × 율"],
|
||||
["accident", "산재보험료", "RT000001", "(직접노무비 + 간접노무비) × 율"],
|
||||
["employment", "고용보험료", "RT000002", "(직접노무비 + 간접노무비) × 율"],
|
||||
["health", "건강보험료", "RT000003", "직접노무비 × 율"],
|
||||
["care", "노인장기요양보험료", "RT000005", "건강보험료 × 율"],
|
||||
["pension", "연금보험료", "RT000004", "직접노무비 × 율"],
|
||||
["safety", "산업안전보건관리비", "RT000006", "A · B 중 작은 쪽 + 기초액"],
|
||||
["other", "기타경비", "RT000009", "(직접노무비 + 간접노무비 + 재료비) × 율"],
|
||||
["environment", "환경보전비", "RT000010", "순공사비 × 율"],
|
||||
["machine", "건설기계대여금지급보증", "RT000015", "순공사비 × 율"],
|
||||
];
|
||||
/** 엑셀에 없는 현행 비목 — 줄만 두고 빈칸 */
|
||||
export const CURRENT_ONLY: Array<[string, string, string]> = [
|
||||
["retirement", "퇴직공제부금비", "RT000011"],
|
||||
["wage_claim", "임금채권보장분담금", "RT000012"],
|
||||
["asbestos", "석면피해구제분담금", "RT000013"],
|
||||
["subcontract", "하도급대금지급보증수수료", "RT000014"],
|
||||
["performance", "공사이행보증수수료", "RT000016"],
|
||||
];
|
||||
/** 소계 아래 줄 */
|
||||
export const TAIL_LINES: Array<[string, string, string, string]> = [
|
||||
["overhead", "일반관리비", "RT000017", "나.소계 × 율"],
|
||||
["profit", "이윤", "RT000018", "(다.소계 − 재료비) × 율 − 보정"],
|
||||
["vat", "부가가치세", "RT000019", "공급가액 × 율 + 조정"],
|
||||
];
|
||||
const SAFETY_VAT_DIVISOR = 1.1;
|
||||
const SAFETY_B = 1.2;
|
||||
|
||||
/** ROUNDDOWN(밑수 × 율 ÷ 100, 0) — 둘 중 빈칸이면 빈칸 */
|
||||
const pct = (base: Num, rate: Num): Num =>
|
||||
base === null || rate === null ? null : rounddown((base * rate) / 100);
|
||||
|
||||
/** 조달수수료 = ROUNDDOWN(관급 순자재대 × 율 ÷ 100, 0) */
|
||||
export const procurementFee = (net: Num, rate: Num): Num => pct(net, rate);
|
||||
|
||||
export function computeTotal(sums: Row | null | undefined, estimate: Row | null | undefined): Row {
|
||||
const s: Row = sums && typeof sums === "object" ? sums : {};
|
||||
const est: Row = estimate && typeof estimate === "object" ? estimate : {};
|
||||
const ratesIn: Row = est.rates && typeof est.rates === "object" ? est.rates : {};
|
||||
const rate: Record<string, Num> = {};
|
||||
for (const [key] of [...RATE_LINES, ...TAIL_LINES]) rate[key] = num(ratesIn[key]);
|
||||
const hand = (key: string): number => num(est[key]) || 0;
|
||||
|
||||
const material = num(s.material);
|
||||
const labor = num(s.labor);
|
||||
const expense = num(s.expense);
|
||||
const supplyNet = num(s.supply_net);
|
||||
const direct = total([material, labor, expense]);
|
||||
|
||||
const line: Record<string, Num> = {};
|
||||
line.indirect_labor = pct(labor, rate.indirect_labor);
|
||||
const totalLabor = total([labor, line.indirect_labor]);
|
||||
line.accident = pct(totalLabor, rate.accident);
|
||||
line.employment = pct(totalLabor, rate.employment);
|
||||
line.health = pct(labor, rate.health);
|
||||
line.care = pct(line.health, rate.care);
|
||||
line.pension = pct(labor, rate.pension);
|
||||
let safetyA: Num = null;
|
||||
let safetyB: Num = null;
|
||||
const laborMaterial = total([labor, material]);
|
||||
if (laborMaterial !== null && rate.safety !== null) {
|
||||
const base = hand("safety_base");
|
||||
const target = laborMaterial + (supplyNet ?? 0) / SAFETY_VAT_DIVISOR;
|
||||
safetyA = rounddown((target * rate.safety) / 100 + base);
|
||||
safetyB = rounddown(((laborMaterial * rate.safety) / 100 + base) * SAFETY_B);
|
||||
}
|
||||
line.safety = safetyA === null || safetyB === null ? null : Math.min(safetyA, safetyB);
|
||||
line.other = pct(total([labor, line.indirect_labor, material]), rate.other);
|
||||
line.environment = pct(direct, rate.environment);
|
||||
line.machine = pct(direct, rate.machine);
|
||||
const netCost = total([direct, ...RATE_LINES.map(([key]) => line[key])]);
|
||||
line.overhead = pct(netCost, rate.overhead);
|
||||
const da = total([netCost, line.overhead]);
|
||||
const profitRaw = pct(da === null || material === null ? null : da - material, rate.profit);
|
||||
line.profit = profitRaw === null ? null : profitRaw - hand("profit_adjust");
|
||||
const supplyPrice = total([da, line.profit]);
|
||||
const vat = pct(supplyPrice, rate.vat);
|
||||
line.vat = vat === null ? null : vat + hand("vat_adjust");
|
||||
const contract = total([supplyPrice, line.vat]);
|
||||
const fee = supplyNet === 0 ? 0 : procurementFee(supplyNet, num(est.procurement_fee_pct));
|
||||
const supplyRaw = total([supplyNet, fee]);
|
||||
line.supply = supplyRaw === null ? null : roundup(supplyRaw, -3);
|
||||
const grand = total([contract, line.supply]);
|
||||
|
||||
const indirectMaterial = hand("indirect_material");
|
||||
const scrap = hand("scrap");
|
||||
const cost: Record<string, Num> = {
|
||||
direct_material: material === null ? null : material - indirectMaterial + scrap,
|
||||
indirect_material: indirectMaterial,
|
||||
scrap,
|
||||
material,
|
||||
direct_labor: labor,
|
||||
indirect_labor: line.indirect_labor,
|
||||
labor: totalLabor,
|
||||
expense,
|
||||
accident: line.accident,
|
||||
employment: line.employment,
|
||||
health: line.health,
|
||||
care: line.care,
|
||||
pension: line.pension,
|
||||
safety: line.safety,
|
||||
other: line.other,
|
||||
environment: line.environment,
|
||||
machine: line.machine,
|
||||
expense_sum: total([
|
||||
expense,
|
||||
...RATE_LINES.filter(([key]) => key !== "indirect_labor").map(([key]) => line[key]),
|
||||
]),
|
||||
overhead: line.overhead,
|
||||
profit: line.profit,
|
||||
supply_price: supplyPrice,
|
||||
vat: line.vat,
|
||||
contract,
|
||||
supply: line.supply,
|
||||
grand,
|
||||
};
|
||||
const share: Record<string, Num> = {};
|
||||
for (const [key, value] of Object.entries(cost))
|
||||
share[key] = value === null || !grand ? null : (value / grand) * 100;
|
||||
return {
|
||||
sums: { material, labor, expense, direct },
|
||||
lines: line,
|
||||
subtotals: { net_cost: netCost, da, supply_price: supplyPrice, contract, grand },
|
||||
safety: { a: safetyA, b: safetyB },
|
||||
profit_raw: profitRaw,
|
||||
supply: { net: supplyNet, fee, raw: supplyRaw },
|
||||
cost,
|
||||
share,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"""B09 원가 요율 후보(PLAN 145-4) — M01 `요율_조달청제비율.json` 을 읽어 줄마다 후보 글 한 줄.
|
||||
|
||||
후보는 보이기만 — 구간(추정가격 · 공기 · 공종 · 연도)을 고르는 값은 사용자 몫이라 확정하지 않음.
|
||||
요율 칸은 빈칸에서 시작(사용자 입력 · `user.estimate.rates`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
FILE = Path(__file__).resolve().parents[1] / "resources" / "master_data" / "요율_조달청제비율.json"
|
||||
|
||||
|
||||
def _percents(value: Any) -> list[float]:
|
||||
"""값 안 모든 rate_percent — 구간 · 연도 · 공종 갈래 통째로."""
|
||||
if isinstance(value, dict):
|
||||
own = value.get("rate_percent")
|
||||
found = [float(own)] if isinstance(own, (int, float)) else []
|
||||
return found + [
|
||||
p for key, v in value.items() if key != "rate_percent" for p in _percents(v)
|
||||
]
|
||||
if isinstance(value, list):
|
||||
return [p for item in value for p in _percents(item)]
|
||||
return []
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def rate_candidates() -> dict[str, str]:
|
||||
"""{RT 키: 「M01 RT… 3.56%」 · 「… 1.01~1.57%(구간 · 고르는 값 사용자)」} — 파일 없으면 {}."""
|
||||
try:
|
||||
rows = json.loads(FILE.read_text(encoding="utf-8")).get("줄") or []
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
out: dict[str, str] = {}
|
||||
for row in rows:
|
||||
percents = sorted(set(_percents(row.get("값"))))
|
||||
if not percents:
|
||||
text = "구간 식(고르는 값 사용자)"
|
||||
elif len(percents) == 1:
|
||||
text = f"{percents[0]:g}%"
|
||||
else:
|
||||
text = f"{percents[0]:g}~{percents[-1]:g}%(구간 · 고르는 값 사용자)"
|
||||
out[str(row.get("키"))] = f"M01 {row.get('키')} {text}"
|
||||
return out
|
||||
@@ -26,7 +26,9 @@ 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_Calc_Total import compute_total
|
||||
from B09_Estimation.B09_Estimation_Linked import linked_resource_keys
|
||||
from B09_Estimation.B09_Estimation_Rates import rate_candidates
|
||||
|
||||
router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"])
|
||||
|
||||
@@ -53,11 +55,15 @@ 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"])
|
||||
user = await asyncio.to_thread(store.read_user, root)
|
||||
sheets = compute_base(inputs, user)
|
||||
# 145-4 총괄 · 원가계산서 = 설계내역서 비목 합(145-3 `bill.sums`) + 사용자 요율 · 손입력
|
||||
sheets["total"] = compute_total((sheets.get("bill") or {}).get("sums"), user.get("estimate"))
|
||||
return {
|
||||
"inputs": inputs,
|
||||
"user": user,
|
||||
"book": {"made": book.get("made")} if book else None,
|
||||
"sheets": compute_base(inputs, user),
|
||||
"sheets": sheets,
|
||||
"rate_candidates": rate_candidates(),
|
||||
"notes": notes,
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ 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 { computeTotal } from "./B09_Estimation_Calc_Total";
|
||||
import {
|
||||
buildCheck,
|
||||
rowIndex,
|
||||
@@ -43,6 +44,7 @@ import {
|
||||
buildMachineBlocks,
|
||||
buildMaterial,
|
||||
} from "./B09_Estimation_UI_Tabs_Base";
|
||||
import { buildCost, buildTotal } from "./B09_Estimation_UI_Tabs_Total";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
@@ -64,6 +66,8 @@ const BUILDERS: Partial<Record<TabId, TabBuild>> = {
|
||||
labor: buildLabor,
|
||||
expense: buildExpense,
|
||||
compare: buildCompare,
|
||||
cost: buildCost,
|
||||
total: buildTotal,
|
||||
};
|
||||
|
||||
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
@@ -89,7 +93,9 @@ function note(textValue: string): HTMLElement {
|
||||
|
||||
/** 브라우저 셈 — 서버 짝과 같은 식(조작 중) */
|
||||
export function computeSheets(payload: Row, user: Row): Row {
|
||||
return computeBase(payload.inputs, user);
|
||||
const sheets = computeBase(payload.inputs, user);
|
||||
// 145-4 총괄 · 원가계산서 = 설계내역서 비목 합(145-3 `bill.sums`) + 사용자 요율 · 손입력
|
||||
return { ...sheets, total: computeTotal(sheets.bill?.sums, user.estimate) };
|
||||
}
|
||||
|
||||
export function renderB09Estimation(root: HTMLElement): void {
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Tabs_Total.ts
|
||||
* B09 총괄설계내역서 · 공사원가계산서 탭(PLAN 145-4) — 실무 엑셀 꼴 · 값 = `B09_Estimation_Calc_Total`.
|
||||
*
|
||||
* 총괄 = 가. 순공사비계(설계내역서 비목 합 · 145-3) + 제비율 줄 + 소계(나~바)
|
||||
* 고칠 칸 = 요율(빈칸 시작 · 후보 = M01 조달청 제비율 글) · 손입력(기초액 · 이윤 보정 ·
|
||||
* 부가세 조정 · 조달수수료율) · 엑셀에 없는 현행 비목 = 줄만(빈칸)
|
||||
* 원가계산서 = 총괄 값 거울 + 구성비 · 고칠 칸 = 간접재료비 · 작업설 · 줄 누름 = 총괄 그 줄로
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DataRow } from "@ui/ui_template_data_table";
|
||||
import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
import { CURRENT_ONLY, RATE_LINES, TAIL_LINES } from "./B09_Estimation_Calc_Total";
|
||||
import {
|
||||
money,
|
||||
parseNumber,
|
||||
qty,
|
||||
text,
|
||||
type TabBuild,
|
||||
type TabContext,
|
||||
type TabTable,
|
||||
} from "./B09_Estimation_UI_Tabs";
|
||||
|
||||
type Num = number | null;
|
||||
|
||||
const won = (v: Num): string => (v === null ? "빈칸" : Math.round(v).toLocaleString("en-US"));
|
||||
|
||||
/** 손입력 칸 — 줄 키 → user.estimate 칸 */
|
||||
const HAND: Record<string, [string, string]> = {
|
||||
safety: ["safety_base", "기초액(원)"],
|
||||
profit: ["profit_adjust", "이윤 보정(원 · 뺌)"],
|
||||
vat: ["vat_adjust", "부가세 조정(원)"],
|
||||
supply: ["procurement_fee_pct", "조달수수료율(%)"],
|
||||
indirect_material: ["indirect_material", "간접재료비(원)"],
|
||||
scrap: ["scrap", "작업설 부산물(원)"],
|
||||
};
|
||||
const RATE_KEYS = new Set([...RATE_LINES, ...TAIL_LINES].map(([key]) => key));
|
||||
|
||||
/** user.estimate 한 칸 고침 — 빈칸이면 지움(빈칸 = 요율 줄 빈칸 · 손입력 0) */
|
||||
function editEstimate(ctx: TabContext, rowKey: string, column: string, value: string): void {
|
||||
const v = parseNumber(value);
|
||||
if (v === undefined) return;
|
||||
const estimate: Row = { ...(ctx.user.estimate || {}) };
|
||||
if (column === "rate" && RATE_KEYS.has(rowKey)) {
|
||||
const rates: Row = { ...(estimate.rates || {}) };
|
||||
if (v === null) delete rates[rowKey];
|
||||
else rates[rowKey] = v;
|
||||
estimate.rates = rates;
|
||||
} else if (HAND[rowKey]) {
|
||||
const field = HAND[rowKey][0];
|
||||
if (v === null) delete estimate[field];
|
||||
else estimate[field] = v;
|
||||
} else return;
|
||||
ctx.setUser({ ...ctx.user, estimate });
|
||||
}
|
||||
|
||||
function rateText(formula: string, rate: Num): string {
|
||||
return rate === null ? `${formula}(요율 빈칸)` : formula.replace("율", `${rate}%`);
|
||||
}
|
||||
|
||||
export const buildTotal: TabBuild = (ctx): TabTable => {
|
||||
const t: Row = ctx.sheets.total || {};
|
||||
const est: Row = ctx.user.estimate || {};
|
||||
const rates: Row = est.rates || {};
|
||||
const cand: Row = ctx.payload.rate_candidates || {};
|
||||
const line: Row = t.lines || {};
|
||||
const sub: Row = t.subtotals || {};
|
||||
const rate = (key: string): Num => (typeof rates[key] === "number" ? rates[key] : null);
|
||||
const hand = (key: string): Num => {
|
||||
const v = est[HAND[key][0]];
|
||||
return typeof v === "number" ? v : null;
|
||||
};
|
||||
const row = (
|
||||
key: string,
|
||||
part: string,
|
||||
name: string,
|
||||
amount: Num,
|
||||
note = "",
|
||||
extra: Partial<DataRow> = {},
|
||||
): DataRow => ({
|
||||
key,
|
||||
part,
|
||||
name,
|
||||
amount,
|
||||
note,
|
||||
rate: null,
|
||||
hand: null,
|
||||
candidate: "",
|
||||
...extra,
|
||||
});
|
||||
const rateRow = ([key, name, rt, formula]: [string, string, string, string], no: number) =>
|
||||
row(key, "", `${no}. ${name}`, line[key] ?? null, rateText(formula, rate(key)), {
|
||||
rate: rate(key),
|
||||
candidate: cand[rt] ?? rt,
|
||||
...(HAND[key] ? { hand: hand(key) } : {}),
|
||||
});
|
||||
const safety: Row = t.safety || {};
|
||||
const supply: Row = t.supply || {};
|
||||
const sums: Row = t.sums || {};
|
||||
const rows: DataRow[] = [
|
||||
row("direct", "가.", "순공사비계", sums.direct ?? null, "설계내역서 비목 합(145-3)", {
|
||||
labor: sums.labor ?? null,
|
||||
material: sums.material ?? null,
|
||||
expense: sums.expense ?? null,
|
||||
}),
|
||||
...RATE_LINES.map((spec, i) => rateRow(spec, i + 1)),
|
||||
...CURRENT_ONLY.map(([key, name, rt]) =>
|
||||
row(key, "", name, null, "현행 비목 · 엑셀에 없음 — 밑수 · 고르는 값 미정(줄만)", {
|
||||
candidate: cand[rt] ?? rt,
|
||||
}),
|
||||
),
|
||||
row("net_cost", "나.", "소계(순공사원가)", sub.net_cost ?? null),
|
||||
rateRow(TAIL_LINES[0], RATE_LINES.length + 1),
|
||||
row("da", "다.", "소계", sub.da ?? null),
|
||||
rateRow(TAIL_LINES[1], RATE_LINES.length + 2),
|
||||
row("supply_price", "라.", "공급가액", sub.supply_price ?? null),
|
||||
rateRow(TAIL_LINES[2], RATE_LINES.length + 3),
|
||||
row("contract", "마.", "도급공사비", sub.contract ?? null),
|
||||
row(
|
||||
"supply",
|
||||
"",
|
||||
`${RATE_LINES.length + 4}. 관급자재대`,
|
||||
line.supply ?? null,
|
||||
`순자재대 ${won(supply.net ?? null)} + 조달수수료 ${won(supply.fee ?? null)} → 천원 올림`,
|
||||
{ hand: hand("supply") },
|
||||
),
|
||||
row("grand", "바.", "총공사비", sub.grand ?? null),
|
||||
];
|
||||
const safetyRow = rows.find((r) => r.key === "safety");
|
||||
if (safetyRow && safety.a !== null && safety.a !== undefined)
|
||||
safetyRow.note = `A(관급 ÷ 1.1 포함) ${won(safety.a)} · B(× 1.2) ${won(safety.b)} → 작은 쪽`;
|
||||
const profitRow = rows.find((r) => r.key === "profit");
|
||||
if (profitRow && typeof t.profit_raw === "number")
|
||||
profitRow.note = `${profitRow.note} · 보정 전 ${won(t.profit_raw)}`;
|
||||
return {
|
||||
columns: [
|
||||
text("part", "공종", 48),
|
||||
text("name", "명칭", 210),
|
||||
money("amount", "합계"),
|
||||
money("labor", "노무비"),
|
||||
money("material", "재료비"),
|
||||
money("expense", "경비"),
|
||||
qty("rate", "요율(%)", 5),
|
||||
money("hand", "손입력", undefined, 2),
|
||||
text("note", "산식", 320),
|
||||
text("candidate", "요율 후보(확정 아님)", 260),
|
||||
],
|
||||
rows,
|
||||
frozen: 2,
|
||||
hint:
|
||||
"끝수 = 엑셀 그대로(줄마다 원 버림 · 관급 천원 올림) · 요율 = 빈칸 시작 — 빈칸이면 그 줄 · 그 줄을 쓰는 소계 빈칸 · " +
|
||||
"손입력 = 안전관리비 기초액 · 이윤 보정 · 부가세 조정 · 조달수수료율(빈칸 = 0) · 공종 제목 줄은 설계내역서(145-3)가 들어오면 붙음",
|
||||
editable: (i, key) =>
|
||||
(key === "rate" && RATE_KEYS.has(String(rows[i]?.key))) ||
|
||||
(key === "hand" && String(rows[i]?.key) in HAND),
|
||||
onEdit: (i, key, value) => editEstimate(ctx, String(rows[i]?.key), key, value),
|
||||
};
|
||||
};
|
||||
|
||||
/** 원가계산서 줄 [키(cost · 총괄 줄), 구분, 비목, 세목] */
|
||||
const COST_ROWS: Array<[string, string, string, string, string?]> = [
|
||||
["direct_material", "순공사원가", "재료비", "직접재료비", "material"],
|
||||
["indirect_material", "순공사원가", "재료비", "간접재료비"],
|
||||
["scrap", "순공사원가", "재료비", "작업설 · 부산물 등(△)"],
|
||||
["material", "순공사원가", "재료비", "소계", "direct"],
|
||||
["direct_labor", "순공사원가", "노무비", "직접노무비", "direct"],
|
||||
["indirect_labor", "순공사원가", "노무비", "간접노무비", "indirect_labor"],
|
||||
["labor", "순공사원가", "노무비", "소계", "direct"],
|
||||
["expense", "순공사원가", "경비", "산출경비", "direct"],
|
||||
...RATE_LINES.filter(([key]) => key !== "indirect_labor").map(
|
||||
([key, name]) =>
|
||||
[key, "순공사원가", "경비", name, key] as [string, string, string, string, string],
|
||||
),
|
||||
["expense_sum", "순공사원가", "경비", "소계", "net_cost"],
|
||||
["overhead", "일반관리비", "", "", "overhead"],
|
||||
["profit", "이윤", "", "", "profit"],
|
||||
["supply_price", "총원가", "", "", "supply_price"],
|
||||
["vat", "부가가치세", "", "", "vat"],
|
||||
["contract", "도급공사비", "", "", "contract"],
|
||||
["supply", "관급자재대", "", "", "supply"],
|
||||
["grand", "총공사비", "", "", "grand"],
|
||||
];
|
||||
|
||||
export const buildCost: TabBuild = (ctx): TabTable => {
|
||||
const t: Row = ctx.sheets.total || {};
|
||||
const cost: Row = t.cost || {};
|
||||
const share: Row = t.share || {};
|
||||
const rates: Row = (ctx.user.estimate || {}).rates || {};
|
||||
const formula: Record<string, string> = Object.fromEntries(
|
||||
[...RATE_LINES, ...TAIL_LINES].map(([key, , , f]) => [
|
||||
key,
|
||||
rateText(f, typeof rates[key] === "number" ? rates[key] : null),
|
||||
]),
|
||||
);
|
||||
const rows: DataRow[] = COST_ROWS.map(([key, group, part, name, link]) => ({
|
||||
key,
|
||||
group,
|
||||
part,
|
||||
name,
|
||||
amount: cost[key] ?? null,
|
||||
share: share[key] ?? null,
|
||||
note: formula[key] ?? "",
|
||||
link: link ?? "",
|
||||
}));
|
||||
return {
|
||||
columns: [
|
||||
text("group", "구분", 96),
|
||||
text("part", "비목", 72),
|
||||
text("name", "세목", 180),
|
||||
money("amount", "금액"),
|
||||
qty("share", "구성비(%)", 2),
|
||||
text("note", "비고", 320),
|
||||
],
|
||||
rows,
|
||||
frozen: 3,
|
||||
hint: "총괄설계내역서 값 거울 · 구성비 = 총공사비 대비 · 고칠 칸 = 간접재료비 · 작업설(직접재료비가 그만큼 바뀌고 재료비 소계는 그대로 — 엑셀 꼴) · 줄 누름 = 총괄 그 줄",
|
||||
editable: (i, key) => key === "amount" && String(rows[i]?.key) in HAND,
|
||||
onEdit: (i, key, value) => editEstimate(ctx, String(rows[i]?.key), key, value),
|
||||
link: (i) => {
|
||||
const target = String(rows[i]?.link || "");
|
||||
return target ? { tab: "total", key: target } : null;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,215 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""145-4 B09 총괄설계내역서 · 공사원가계산서 셈 — 엑셀 검산 대조 · 거울(`B09_Estimation_Calc_Total.py` ↔ `.ts`).
|
||||
|
||||
견본 = 기번3 공통 엑셀(`2024년 간선임도사업(기번3).xlsx`) 총괄 48 비목 합 · M49~M64 요율 · N55 기초액 ·
|
||||
I62 이윤 보정 · 설계내역서 J253 관급 순자재대 · D253 조달수수료율 → 총괄 D49~D67 · E55 A/B · 원가계산서 구성비.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B09_Estimation.B09_Estimation_Calc_Total import compute_total # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Rates import rate_candidates # noqa: E402
|
||||
|
||||
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
|
||||
|
||||
SUMS = {"material": 194234417, "labor": 346800554, "expense": 169324165, "supply_net": 69474220}
|
||||
RATES = {
|
||||
"indirect_labor": 14.5,
|
||||
"accident": 3.56,
|
||||
"employment": 1.01,
|
||||
"health": 3.545,
|
||||
"care": 12.95,
|
||||
"pension": 4.5,
|
||||
"safety": 1.86,
|
||||
"other": 6,
|
||||
"environment": 0.8,
|
||||
"machine": 0.4,
|
||||
"overhead": 6,
|
||||
"profit": 15,
|
||||
"vat": 10,
|
||||
}
|
||||
ESTIMATE = {
|
||||
"rates": RATES,
|
||||
"safety_base": 5349000,
|
||||
"profit_adjust": 905980,
|
||||
"procurement_fee_pct": 0.54,
|
||||
}
|
||||
|
||||
EXCEL_LINES = { # 총괄 D49~D58 · D60 · D62 · D64 · D66
|
||||
"indirect_labor": 50286080,
|
||||
"accident": 14136284,
|
||||
"employment": 4010575,
|
||||
"health": 12294079,
|
||||
"care": 1592083,
|
||||
"pension": 15606024,
|
||||
"safety": 16586996,
|
||||
"other": 35479263,
|
||||
"environment": 5682873,
|
||||
"machine": 2841436,
|
||||
"overhead": 52132489,
|
||||
"profit": 108109955,
|
||||
"vat": 102911727,
|
||||
"supply": 69850000,
|
||||
}
|
||||
EXCEL_SUBTOTALS = { # 총괄 D59 · D61 · D63 · D65 · D67
|
||||
"net_cost": 868874829,
|
||||
"da": 921007318,
|
||||
"supply_price": 1029117273,
|
||||
"contract": 1132029000,
|
||||
"grand": 1201879000,
|
||||
}
|
||||
|
||||
|
||||
def test_엑셀_검산_총괄():
|
||||
out = compute_total(SUMS, ESTIMATE)
|
||||
assert out["sums"]["direct"] == 710359136 # 가. 순공사비계 D48
|
||||
assert out["lines"] == EXCEL_LINES
|
||||
assert out["subtotals"] == EXCEL_SUBTOTALS
|
||||
assert out["safety"] == {"a": 16586996, "b": 18494700} # E55 글 A · B
|
||||
assert out["profit_raw"] == 109015935 # J62 보정 전
|
||||
assert out["supply"] == {"net": 69474220, "fee": 375160, "raw": 69849380} # I66 · O66
|
||||
|
||||
|
||||
def test_엑셀_검산_원가계산서_거울_구성비():
|
||||
cost = compute_total(SUMS, ESTIMATE)["cost"]
|
||||
share = compute_total(SUMS, ESTIMATE)["share"]
|
||||
assert cost["direct_material"] == 194234417 and cost["material"] == 194234417 # D5 · D8
|
||||
assert cost["labor"] == 397086634 # D11
|
||||
assert cost["expense_sum"] == 277553778 # D22
|
||||
assert cost["supply_price"] == 1029117273 and cost["grand"] == 1201879000 # D25 · D29
|
||||
assert share["direct_material"] == pytest.approx(16.160896146783497) # E5
|
||||
assert share["labor"] == pytest.approx(33.03881954839048) # E11
|
||||
assert share["supply"] == pytest.approx(5.8117331278772655) # E28
|
||||
assert share["grand"] == 100
|
||||
|
||||
|
||||
def test_간접재료비_작업설은_직접재료비만_바꿈():
|
||||
est = {**ESTIMATE, "indirect_material": 1000, "scrap": 300}
|
||||
out = compute_total(SUMS, est)
|
||||
assert out["cost"]["direct_material"] == 194234417 - 1000 + 300 # 엑셀 D5 = F48 − D6 + D7
|
||||
assert out["cost"]["material"] == 194234417
|
||||
assert out["subtotals"] == EXCEL_SUBTOTALS
|
||||
|
||||
|
||||
def test_빈칸_규칙():
|
||||
blank = compute_total(SUMS, {})
|
||||
assert all(v is None for k, v in blank["lines"].items() if k != "supply")
|
||||
assert blank["subtotals"]["net_cost"] is None and blank["subtotals"]["grand"] is None
|
||||
assert blank["lines"]["supply"] is None # 조달수수료율 빈칸
|
||||
# 요율 하나만 빈칸 — 그 줄 · 소계 빈칸 · 다른 줄은 셈
|
||||
one = compute_total(SUMS, {**ESTIMATE, "rates": {**RATES, "care": None}})
|
||||
assert one["lines"]["care"] is None and one["lines"]["health"] == 12294079
|
||||
assert one["subtotals"]["net_cost"] is None and one["lines"]["supply"] == 69850000
|
||||
# 비목 합이 아직 없음(145-3 전) — 모두 빈칸
|
||||
none = compute_total(None, ESTIMATE)
|
||||
assert none["subtotals"]["grand"] is None and none["lines"]["indirect_labor"] is None
|
||||
# 관급 없음 — 수수료율 빈칸이어도 관급 0
|
||||
zero = compute_total({**SUMS, "supply_net": 0}, {**ESTIMATE, "procurement_fee_pct": None})
|
||||
assert zero["lines"]["supply"] == 0 and zero["subtotals"]["grand"] is not None
|
||||
|
||||
|
||||
def test_안전관리비_B_가_작으면_B():
|
||||
big = compute_total({**SUMS, "supply_net": 2_000_000_000}, ESTIMATE)
|
||||
assert big["safety"]["a"] > big["safety"]["b"]
|
||||
assert big["lines"]["safety"] == big["safety"]["b"]
|
||||
|
||||
|
||||
def test_요율_후보_글():
|
||||
cand = rate_candidates()
|
||||
assert cand["RT000001"] == "M01 RT000001 3.56%"
|
||||
assert "구간" in cand["RT000002"] and cand["RT000019"].endswith("10%")
|
||||
|
||||
|
||||
def _run_ts(cases: list) -> list:
|
||||
base = ROOT / "B09_Estimation" / "B09_Estimation_Calc_Base.ts"
|
||||
calc = ROOT / "B09_Estimation" / "B09_Estimation_Calc_Total.ts"
|
||||
body = (
|
||||
f"\nconst cases = {json.dumps(cases, ensure_ascii=False)};"
|
||||
"\nconsole.log(JSON.stringify(cases.map(([s, e]) => computeTotal(s, e))));"
|
||||
)
|
||||
script = "".join(IMPORTS.sub("", p.read_text(encoding="utf-8")) for p in (base, calc)) + body
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", script],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_거울_TS_가_같은_값():
|
||||
cases = [
|
||||
[SUMS, ESTIMATE],
|
||||
[SUMS, {**ESTIMATE, "indirect_material": 1000, "scrap": 300, "vat_adjust": -27}],
|
||||
[SUMS, {}],
|
||||
[None, ESTIMATE],
|
||||
[{**SUMS, "supply_net": 0}, {**ESTIMATE, "procurement_fee_pct": None}],
|
||||
[{**SUMS, "supply_net": 2_000_000_000}, ESTIMATE],
|
||||
[{**SUMS, "labor": None}, ESTIMATE],
|
||||
[SUMS, {**ESTIMATE, "rates": {**RATES, "care": None, "profit": 12.5}}],
|
||||
]
|
||||
assert _run_ts(cases) == [compute_total(s, e) for s, e in cases]
|
||||
|
||||
|
||||
def test_화면_탭_요율_손입력_칸():
|
||||
"""총괄 탭 — 요율 · 손입력 칸만 고침 · 빈칸 = 지움 · 원가계산서 줄 누름 = 총괄 그 줄."""
|
||||
files = [
|
||||
ROOT / "B09_Estimation" / name
|
||||
for name in (
|
||||
"B09_Estimation_Calc_Base.ts",
|
||||
"B09_Estimation_Calc_Total.ts",
|
||||
"B09_Estimation_UI_Tabs.ts",
|
||||
"B09_Estimation_UI_Tabs_Total.ts",
|
||||
)
|
||||
]
|
||||
body = f"""
|
||||
let user = {{ estimate: {json.dumps(ESTIMATE)} }};
|
||||
const setUser = (u) => (user = u);
|
||||
const sums = {json.dumps(SUMS)};
|
||||
const ctx = () => ({{ sheets: {{ total: computeTotal(sums, user.estimate) }}, payload: {{}}, user, setUser }});
|
||||
let t = buildTotal(ctx());
|
||||
const at = (rows, key) => rows.findIndex((r) => r.key === key);
|
||||
const out = {{}};
|
||||
out.editable = [
|
||||
t.editable(at(t.rows, "care"), "rate"), t.editable(at(t.rows, "care"), "hand"),
|
||||
t.editable(at(t.rows, "safety"), "hand"), t.editable(at(t.rows, "retirement"), "rate"),
|
||||
t.editable(at(t.rows, "net_cost"), "rate"),
|
||||
];
|
||||
t.onEdit(at(t.rows, "care"), "rate", "13.14");
|
||||
out.care = user.estimate.rates.care;
|
||||
t = buildTotal(ctx());
|
||||
t.onEdit(at(t.rows, "care"), "rate", "");
|
||||
out.careGone = "care" in user.estimate.rates;
|
||||
t = buildTotal(ctx());
|
||||
out.grandBlank = t.rows[at(t.rows, "grand")].amount;
|
||||
t.onEdit(at(t.rows, "safety"), "hand", "1,000");
|
||||
out.base = user.estimate.safety_base;
|
||||
const c = buildCost(ctx());
|
||||
out.link = c.link(at(c.rows, "safety"));
|
||||
c.onEdit(at(c.rows, "scrap"), "amount", "300");
|
||||
out.scrap = user.estimate.scrap;
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
script = "".join(IMPORTS.sub("", p.read_text(encoding="utf-8")) for p in files) + body
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", script],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
out = json.loads(result.stdout)
|
||||
assert out["editable"] == [True, False, True, False, False]
|
||||
assert out["care"] == 13.14 and out["careGone"] is False
|
||||
assert out["grandBlank"] is None # 요율 지우면 그 줄 · 소계 빈칸
|
||||
assert out["base"] == 1000 and out["scrap"] == 300
|
||||
assert out["link"] == {"tab": "total", "key": "safety"}
|
||||
Reference in New Issue
Block a user