Files
Aislo/B09_Estimation/B09_Estimation_Calc_Total.ts
T
eomsangdonandClaude Opus 5.5 58283a578f feat(b09): 총괄 요율 자동 고름 · 사용자 값 앞섬 · 고르는 값 칸(166-1)
- Rates.rate_table: M01 조달청 제비율 압축 표(구간 위 끝 · 토목) → payload rate_table
- Calc_Total 짝(py · ts): pick_rates 자동 고름(직접공사비 × 공기 · 추정가격 · 안전 대상액 + 기초액 · 연도 · 환경 공종) · 사용자 값 앞섬
- 총괄 탭: 추정가격 · 공사기간 · 환경보전비 공종 칸 · 요율 출처 칸(자동 근거 풍선 · 누르면 자동으로)
- Store: environment_kind 칸
- 시험: test_166_1 새 · test_145_4 node 입력을 stdin 으로(명령 줄 길이)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
2026-10-10 00:06:34 +09:00

232 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* B09_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`) ·
* table = M01 요율 압축 표(payload `rate_table`) · year = 연금 해 — 자동 고르기(166-1) = 파이썬 짝 머리 글
* 식 · 끝수 = 엑셀 총괄 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);
/** 구간 번호 — 위 끝(그 값 미만) 목록에서 value 가 넘은 개수 */
const step = (bounds: number[], value: number): number => bounds.filter((b) => value >= b).length;
function band(bounds: number[], i: number, days = false): string {
const fmt = (v: number): string => (days ? `${v}일` : `${v / 1e8}억`);
if (i === 0) return `${fmt(bounds[0])} 미만`;
if (i === bounds.length) return `${fmt(bounds[bounds.length - 1])} 이상`;
return `${fmt(bounds[i - 1])} 이상 ${fmt(bounds[i])} 미만`;
}
/** 표 한 줄 → [자동 율, 기초액, 까닭 글] — 고르는 값이 빈칸이면 율 null */
function pick(spec: Row, ctx: Row): [Num, Num, string] {
if ("pct" in spec) return [spec.pct, null, "단일 요율"];
if ("price" in spec) {
if (ctx.price === null) return [null, null, "추정가격 빈칸"];
const i = step(spec.price, ctx.price);
return [spec.pcts[i], null, `추정가격 ${band(spec.price, i)}`];
}
if ("grid" in spec) {
if (ctx.direct === null || ctx.days === null) return [null, null, "직접공사비 · 공기 빈칸"];
const i = step(spec.direct, ctx.direct);
const j = step(spec.days, ctx.days);
const why = `직접공사비 ${band(spec.direct, i)} · 공기 ${band(spec.days, j, true)}`;
return [spec.grid[i][j], null, why];
}
if ("target" in spec) {
if (ctx.target === null) return [null, null, "대상액 빈칸"];
const i = step(spec.target, ctx.target);
return [spec.pcts[i], spec.bases[i], `대상액 ${band(spec.target, i)}`];
}
if ("years" in spec) {
const pct = spec.years[String(ctx.year)] ?? null;
return [pct, null, pct !== null ? `${ctx.year}년` : `${ctx.year}년 요율 없음`];
}
if ("kinds" in spec) {
const kind = ctx.env === "road" ? "road" : "other";
return [spec.kinds[kind] ?? null, null, kind === "road" ? "도로" : "그 밖의 토목"];
}
return [null, null, ""];
}
/** {줄 키: {rate, base, why}} — 표 없으면 {}(자동 없음) */
export function pickRates(table: Row | null | undefined, ctx: Row): Record<string, Row> {
const out: Record<string, Row> = {};
for (const [key, spec] of Object.entries(table && typeof table === "object" ? table : {})) {
const [rate, base, why] = pick(spec as Row, ctx);
const note = (spec as Row).note || "";
out[key] = { rate, base, why: note ? `${why} · ${note}` : why };
}
return out;
}
export function computeTotal(
sums: Row | null | undefined,
estimate: Row | null | undefined,
table?: Row | null,
year?: number | null,
): 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 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 laborMaterial = total([labor, material]);
const target =
laborMaterial === null ? null : laborMaterial + (supplyNet ?? 0) / SAFETY_VAT_DIVISOR;
const auto = pickRates(table, {
direct,
target,
price: num(est.estimated_price),
days: num(est.duration_days),
year: year || new Date().getFullYear(),
env: est.environment_kind ?? null,
});
const rates: Record<string, Row> = {};
for (const [key] of [...RATE_LINES, ...TAIL_LINES]) {
const mine = num(ratesIn[key]);
const got: Row = auto[key] || {};
const autoRate: Num = got.rate ?? null;
rates[key] = {
rate: mine === null ? autoRate : mine,
from: mine !== null ? "user" : autoRate !== null ? "auto" : null,
auto: autoRate,
why: got.why || "",
};
}
const rate: Record<string, Num> = {};
for (const [key, r] of Object.entries(rates)) rate[key] = r.rate;
const baseIn = num(est.safety_base);
const safetyBase: number = baseIn !== null ? baseIn : (auto.safety || {}).base || 0;
rates.safety.base = safetyBase;
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;
if (laborMaterial !== null && target !== null && rate.safety !== null) {
const base = safetyBase;
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,
rates,
};
}