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