Files
Aislo/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts
T
eomsangdonandClaude Sonnet 5.5 f0f35c5749 fix(b09): 공사원가계산서 직접재료비 이동 대상을 총괄 가. 순공사비계(direct)로 (261-2)
- 이동 대상 키 material 이 총괄 줄에 없어 빨강 상자가 안 뜸
- 시험 추가 - 모든 원가 줄 이동 대상이 총괄 줄 키에 있는지

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
2026-10-11 15:16:12 +09:00

348 lines
14 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 조달청 제비율 · 짝 셈이 고름) · 사용자가 적으면 앞섬 · 출처 칸 누름 = 자동으로
* 고칠 칸 = 요율 · 손입력(기초액 · 이윤 보정 · 부가세 조정 · 조달수수료율) ·
* 엑셀에 없는 현행 비목 = 줄만(빈칸)
* 원가계산서 = 총괄 값 거울 + 구성비 · 고칠 칸 = 간접재료비 · 작업설 · 줄 누름 = 총괄 그 줄로
* 레벨(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,
} 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", "작업설 부산물(원)"],
};
const RATE_KEYS = new Set([...RATE_LINES, ...TAIL_LINES].map(([key]) => 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)) {
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 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") {
const rates: Row = { ...(estimate.rates || {}) };
delete rates[rowKey];
estimate.rates = rates;
} 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): 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 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 (r.from === "user")
return `사용자 값 · 누르면 자동${r.auto === null || r.auto === undefined ? "(없음)" : ` ${r.auto}%`}으로`;
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)), {
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)),
...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") },
),
];
// 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"],
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),
]),
);
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"],
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;
},
};
};