- 화면 글 10 자리(8 파일) — 세월교 힌트 · B08 재료 엑셀 알림 · B09 내역 수기 줄 까닭(거울 둘) · 산근 쓰는 곳 · 단가 힌트 · 총괄 수기 입력 칸 · 로케일 둘 - 저장 키 · 주석 · 손료는 그대로 · 로케일 줄 수 그대로 - 걸린 시험 셋 글 맞춤 · 새 시험 test_171_1 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X
286 lines
12 KiB
TypeScript
286 lines
12 KiB
TypeScript
/* =============================================================================
|
||
* B09_Estimation_UI_Tabs_Total.ts
|
||
* B09 총괄설계내역서 · 공사원가계산서 탭(PLAN 145-4) — 실무 엑셀 꼴 · 값 = `B09_Estimation_Calc_Total`.
|
||
*
|
||
* 총괄 = 가. 순공사비계(설계내역서 비목 합 · 145-3) + 제비율 줄 + 소계(나~바)
|
||
* 맨 위 = 요율 고르는 값(추정가격 · 공사기간 · 환경보전비 공종 — 누르면 도로 ↔ 그 밖)(166-1)
|
||
* 요율 = 자동(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]> = {
|
||
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 });
|
||
}
|
||
|
||
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: "",
|
||
...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[] = [
|
||
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") },
|
||
),
|
||
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:
|
||
"요율 = M01 조달청 제비율에서 자동(맨 위 고르는 값 · 공종 토목) — 칸에 적으면 사용자 값이 앞섬 · 지우거나 출처 칸 누르면 자동 · " +
|
||
"2024 엑셀 요율과 연도 차로 다를 수 있음 · 자동도 없으면 그 줄 · 그 줄을 쓰는 소계 빈칸 · 끝수 = 엑셀 그대로(줄마다 원 버림 · 관급 천원 올림) · " +
|
||
"수기 입력 = 안전관리비 기초액(빈칸 = 자동 구간 기초액) · 이윤 보정 · 부가세 조정 · 조달수수료율(빈칸 = 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),
|
||
onCell: (i, key) => clickEstimate(ctx, String(rows[i]?.key), key, rows[i]?.from),
|
||
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", "순공사원가", "재료비", "직접재료비", "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 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 ?? "",
|
||
}));
|
||
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;
|
||
},
|
||
};
|
||
};
|