Files
Aislo/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts
T
eomsangdonandClaude Opus 5.5 8013fd901b 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
2026-10-09 15:12:40 +09:00

225 lines
9.3 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_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;
},
};
};