- 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
232 lines
10 KiB
TypeScript
232 lines
10 KiB
TypeScript
/* =============================================================================
|
||
* 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,
|
||
};
|
||
}
|