Files
Aislo/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts
T
eomsangdonandClaude Opus 5.5 472bf98ba2 feat(B09 원가계산서 · 708-2): 현행 비목 다섯 셈 — 퇴직공제 · 임금채권 · 석면 · 하도급 요율 셈 · 공사이행보증 금액 손입력
- 셈 짝(Calc_Total py ↔ ts) — STmate 일반형식 식 · 자리(지식DB 12 · 35 분석) · 경비 묶음 안 · 나.소계에 듦
  퇴직공제부금비 = 직접노무비 × 율 · 임금채권 · 석면 = (직노 + 간노) × 율 · 하도급 = 순공사비 × 율 ·
  공사이행보증 = 금액 직접 입력(performance_fee) · 자동 요율 없음 · 요율 빈칸 = 해당 없음(0)
- 요율 출처 「불러옴」 = rates_imported(707-2 불러오기가 실음 · 요율 고치면 사용자)
- 707-2 불러오기 — 「셈 없음」 알림 뺌 · 공사이행보증 줄 금액 = 손입력 칸
- 총괄 · 원가계산서 탭 · 인쇄 미리보기 = 같은 줄 짜기
- 낡은 시험 고침 — 현행 비목 기대값 빈칸 → 0 · 퇴직공제 요율 칸 고침 가능 · 거창 총공사비 = 엑셀

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 21:27:40 +09:00

362 lines
15 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`.
*
* 총괄 = 맨 위 내역 줄(239-1 · 설계내역서 레벨 1 · 2 집계 + 순공사비 계 · 누름 = 설계내역서 그 줄 빨강) +
* 가. 순공사비계(설계내역서 비목 합 · 145-3) + 제비율 줄 + 소계(나~바)
* 맨 위 = 요율 고르는 값(추정가격 · 공사기간 · 환경보전비 공종 — 누르면 도로 ↔ 그 밖)(166-1)
* 요율 = 자동(M01 조달청 제비율 · 짝 셈이 고름) · 사용자가 적으면 앞섬 · 출처 칸 누름 = 자동으로
* 고칠 칸 = 요율 · 손입력(기초액 · 이윤 보정 · 부가세 조정 · 조달수수료율) ·
* 현행 비목(708-2) = 경비 묶음 안 · 요율 빈칸 = 해당 없음(0) · 공사이행보증 = 금액 손입력 ·
* 요율 출처 = 사용자 · 불러옴(707-2 `rates_imported`) — 요율을 고치면 사용자
* 원가계산서 = 총괄 값 거울 + 구성비 · 고칠 칸 = 간접재료비 · 작업설 · 줄 누름 = 총괄 그 줄로
* 레벨(185-1 · 201-1) = 줄 `level`(화면이 정함 · 줄 짜임이 이 파일에 있음 · 셈 짝은 값만) →
* 공용 표 level 옵션(명칭 칸 들여쓰기 · 보기 전용) · 1단 = 굵게(is-title)
* 총괄 = 가.~바. 소계 1 · 그 밖 2 · 원가계산서 = 구분만 1 · 비목 소계 2 · 세목 3
* ========================================================================== */
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,
unfinished,
} 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]> = {
in_price: ["estimated_price", "추정가격(원)"],
in_days: ["duration_days", "공사기간(일)"],
safety: ["safety_base", "기초액(원)"],
profit: ["profit_adjust", "이윤 보정(원 · 뺌)"],
vat: ["vat_adjust", "부가세 조정(원)"],
supply: ["procurement_fee_pct", "조달수수료율(%)"],
indirect_material: ["indirect_material", "간접재료비(원)"],
scrap: ["scrap", "작업설 부산물(원)"],
performance: ["performance_fee", "공사이행보증수수료(원)"],
};
/** 요율 칸을 고치는 줄 — 공사이행보증 = 금액 손입력(율 칸 없음) */
const RATE_KEYS = new Set(
[...RATE_LINES, ...TAIL_LINES].map(([key]) => key).filter((key) => key !== "performance"),
);
/** 요율 줄 사용자 값 고침 · 지움 — 불러옴 표시도 뗌 */
function setRate(estimate: Row, key: string, v: number | null): void {
const rates: Row = { ...(estimate.rates || {}) };
if (v === null) delete rates[key];
else rates[key] = v;
estimate.rates = rates;
if (Array.isArray(estimate.rates_imported))
estimate.rates_imported = estimate.rates_imported.filter((k: string) => k !== key);
}
const ENV_NAME: Record<string, string> = { road: "도로", other: "그 밖의 토목" };
/** 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)) setRate(estimate, rowKey, v);
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 clickEstimate(ctx: TabContext, rowKey: string, column: string, from: unknown): void {
const estimate: Row = { ...(ctx.user.estimate || {}) };
if (rowKey === "in_env") {
if (estimate.environment_kind === "road") delete estimate.environment_kind;
else estimate.environment_kind = "road";
} else if (column === "candidate" && (from === "user" || from === "import"))
setRate(estimate, rowKey, null);
else return;
ctx.setUser({ ...ctx.user, estimate });
}
/** 185-1 레벨 꼴 — 1단 = 굵게 · 들여쓰기 = 공용 표 level(명칭 칸) */
const levelClass = (r: DataRow | undefined): string | null => (r?.level === 1 ? "is-title" : null);
const nameLevel = (rows: DataRow[]) => ({
key: "name",
of: (i: number) => rows[i]?.level as number | undefined,
});
/** 239-1 맨 위 내역 줄 = 설계내역서 레벨 1 · 2 폴더 줄(관급 빼고 · 레벨 1 항목도 — 합이 순공사비) ·
* 금액 = 설계내역서 셈 그대로 · 끝 = 순공사비 계 · 누름 = 설계내역서 그 줄(`bill`) */
function billRows(bill: Row | undefined): DataRow[] {
const top = (bill?.rows || []).filter(
(r: Row) => r.cost === "direct" && (r.level === 1 || (r.level === 2 && r.kind !== "item")),
);
if (!top.length) return [];
const direct: Row = bill?.totals?.direct || {};
const amounts = (r: Row) => ({
amount: r.sum ?? null,
labor: r.labor ?? null,
material: r.material ?? null,
expense: r.expense ?? null,
});
const blank = direct.blank ? ` · 금액 빈 줄 ${direct.blank} — 아는 금액만` : "";
return [
...top.map((r: Row) => ({
key: `bill:${r.key}`,
part: "내역",
name: r.no ? `${r.no} ${r.name}` : r.name,
...amounts(r),
note: "누르면 설계내역서 그 줄로",
level: r.level,
bill: r.key,
})),
{
key: "bill_sum",
part: "내역",
name: "순공사비 계",
...amounts(direct),
note: `설계내역서 레벨 1 합(관급 제외)${blank}`,
level: 1,
},
];
}
function rateText(formula: string, rate: Num, key = ""): string {
if (key === "performance") return formula;
if (rate === null)
return CURRENT_ONLY.includes(key)
? `해당 없음(요율 빈칸) — ${formula}`
: `${formula}(요율 빈칸)`;
return formula.replace("율", `${rate}%`);
}
export const buildTotal: TabBuild = (ctx): TabTable => {
const t: Row = ctx.sheets.total || {};
const est: Row = ctx.user.estimate || {};
const picked: Row = t.rates || {};
const cand: Row = ctx.payload.rate_candidates || {};
const line: Row = t.lines || {};
const sub: Row = t.subtotals || {};
const rate = (key: string): Num => picked[key]?.rate ?? null;
/** 출처 칸 글 — 사용자 · 자동 까닭 · 자동 없음 */
const source = (key: string): string => {
const r: Row = picked[key] || {};
if (key === "performance") return "금액 직접 입력(율 셈 없음)";
if (r.from === "user" || r.from === "import")
return `${r.from === "user" ? "사용자" : "불러옴"} 값 · 누르면 자동${r.auto === null || r.auto === undefined ? "(없음)" : ` ${r.auto}%`}으로`;
if (CURRENT_ONLY.includes(key)) return "사용자 · 불러옴만(자동 없음)";
return r.from === "auto" ? `자동 — ${r.why}` : `자동 없음${r.why ? ` — ${r.why}` : ""}`;
};
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: "",
level: part.endsWith(".") ? 1 : 2,
...extra,
});
const rateRow = ([key, name, rt, formula]: [string, string, string, string], no: number) =>
row(key, "", `${no}. ${name}`, line[key] ?? null, rateText(formula, rate(key), key), {
rate: rate(key),
candidate: source(key),
from: picked[key]?.from ?? null,
rt,
...(HAND[key] ? { hand: hand(key) } : {}),
});
const safety: Row = t.safety || {};
const supply: Row = t.supply || {};
const sums: Row = t.sums || {};
const env = est.environment_kind === "road" ? "road" : "other";
const rows: DataRow[] = [
...billRows(ctx.sheets.bill),
row("in_price", "요율", "추정가격(원)", null, "일반관리비 · 이윤 · 고용보험 구간", {
hand: hand("in_price"),
}),
row(
"in_days",
"요율",
"공사기간(일)",
null,
"간접노무비 · 기타경비 구간(직접공사비 = 순공사비계)",
{
hand: hand("in_days"),
},
),
row(
"in_env",
"요율",
`환경보전비 공종 = ${ENV_NAME[env]}`,
null,
`누르면 ${ENV_NAME[env === "road" ? "other" : "road"]}으로`,
),
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)),
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") },
),
];
// 257-2 총공사비 = 표 아래 고정 줄(템플릿 total · 몸 줄 아님 · 줄 이동 누름 없음)
const grand = 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,
total: grand,
frozen: 2,
rowClass: (i) => levelClass(rows[i]),
level: nameLevel(rows),
mergeSame: ["part"],
banner: unfinished(ctx.sheets) ?? undefined,
hint: "맨 위 내역 줄을 누르면 설계내역서 그 줄로 · 요율 칸에 적으면 사용자 값 · 지우거나 근거 칸을 누르면 자동 값",
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),
onCell: (i, key) => clickEstimate(ctx, String(rows[i]?.key), key, rows[i]?.from),
link: (i) => (rows[i]?.bill ? { tab: "bill", key: String(rows[i].bill) } : null),
tip: (i, key) => {
const r = rows[i];
if (!r?.rt || (key !== "rate" && key !== "candidate")) return null;
const why = picked[String(r.key)]?.why;
return [why ? `자동 근거 — ${why}` : "", cand[String(r.rt)] ?? r.rt]
.filter(Boolean)
.join(" · ");
},
};
};
/** 원가계산서 줄 [키(cost · 총괄 줄), 구분, 비목, 세목] */
const COST_ROWS: Array<[string, string, string, string, string?]> = [
["direct_material", "순공사원가", "재료비", "직접재료비", "direct"],
["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) = 표 아래 고정 줄(257-2 · buildCost total)
export const buildCost: TabBuild = (ctx): TabTable => {
const t: Row = ctx.sheets.total || {};
const cost: Row = t.cost || {};
const share: Row = t.share || {};
const picked: Row = t.rates || {};
const formula: Record<string, string> = Object.fromEntries(
[...RATE_LINES, ...TAIL_LINES].map(([key, , , f]) => [
key,
rateText(f, picked[key]?.rate ?? null, key),
]),
);
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 ?? "",
level: !part ? 1 : name === "소계" ? 2 : 3,
}));
return {
// 257-2 총공사비 = 표 아래 고정 줄(템플릿 total · 줄 이동 누름 없음)
total: {
key: "grand",
group: "총공사비",
amount: cost.grand ?? null,
share: share.grand ?? null,
note: formula.grand ?? "",
},
columns: [
text("group", "구분", 96),
text("part", "비목", 72),
text("name", "세목", 180),
money("amount", "금액"),
qty("share", "구성비(%)", 2),
text("note", "비고", 320),
],
rows,
frozen: 3,
rowClass: (i) => levelClass(rows[i]),
level: nameLevel(rows),
mergeSame: ["group", "part"],
banner: unfinished(ctx.sheets) ?? undefined,
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;
},
};
};