- 수요: 사본 일위대가 자재 · 굳힌 사전조합 자재 코드도(Linked.used_keys) → 대가 재료 줄 단가가 MT 줄에서 남 - 두 번 셈(Router.compute_twice): 1차 셈 → 대가 · 산근 · 중기 재료 줄 · B08 줄 · 관급 · 별도 줄만 남김(Summary.used_materials) · 1차 역전개 총수량 = material_qty → 2차 셈 - 수량 원천: Calc_Base 짝 material_sheet 가 material_qty(없는 키 빈칸) · 없으면 입력 줄 수량(거울) - 화면: 비고 = 쓰는 곳(대가 · 산근 · 중기 n호표 · 내역 자재대) · 안내 글 - 실측 엄상준_검증: 141 → 24줄 · 목록 수량 = 재료비수량금액집계표 수량 · 목록표에 없음 0 - 시험: test_183_b09_material_list Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
351 lines
13 KiB
TypeScript
351 lines
13 KiB
TypeScript
/* =============================================================================
|
||
* B09_Estimation_UI_Tabs_Base.ts
|
||
* B09 바탕 단가 탭 일곱(PLAN 144-2~4) — 노무비 · 경비목록표 · 환율및기초자료 · 자재단가대비표 ·
|
||
* 재료비목록표 · 중기사용료 · 중기목록표(실무 엑셀 꼴 · 값 = `B09_Estimation_Calc_Base`).
|
||
* 재료비목록표 = 엑셀 꼴 + 수량 · 총액 열(166-3) · 줄 = 대가 · 산근 · 중기가 쓰는 자재 + B08 줄 ·
|
||
* 관급 · 별도 줄(183-1 · 서버가 거른 입력) · 수량 = 역전개 총수량 · 비고 = 쓰는 곳(`usesOf`)
|
||
*
|
||
* 고칠 칸 = 자재단가대비표 적용 단가(빈칸으로 지우면 최저 자동) · 관급 / 별도 칸 누름 = 표시 바꿈
|
||
* · 견적 단가 · 면 손입력 · 적용 「출처」 칸 누름 = 고를 출처 돌림(값 있는 출처만 · 견적이 높아도)
|
||
* · 「견적서」 칸 누름 = 파일 모달(`B09_Estimation_UI_Tabs_Base_Quote`)(166-4)
|
||
* 이동 = 재료비목록표 줄 → 대비표 · 중기목록표 줄 → 중기사용료 블록 · 블록 줄 → 경비 · 기초 · 대비표
|
||
* ========================================================================== */
|
||
|
||
import type { DataRow } from "@ui/ui_template_data_table";
|
||
import { SOURCES, type Row } from "./B09_Estimation_Calc_Base";
|
||
import { openQuotes } from "./B09_Estimation_UI_Tabs_Base_Quote";
|
||
import {
|
||
money,
|
||
parseNumber,
|
||
qty,
|
||
text,
|
||
type TabBuild,
|
||
type TabId,
|
||
type TabTable,
|
||
} from "./B09_Estimation_UI_Tabs";
|
||
|
||
const LINK_HINT = "줄을 누르면 단가를 낸 탭 · 줄로 이동";
|
||
|
||
/** 단가 줄 표(노무비 · 경비목록표) — 호표 · 명칭 · 규격 · 단위 · 단가 · 비고(출처) */
|
||
function priceList(rows: Row[], unitPrice: string, hint: string): TabTable {
|
||
return {
|
||
columns: [
|
||
qty("no", "호표", 0),
|
||
text("name", "명칭", 200),
|
||
text("spec", "규격", 140),
|
||
text("unit", "단위", 56),
|
||
money("price", unitPrice),
|
||
text("source", "비고", 220),
|
||
],
|
||
rows: rows.map((r) => ({ ...r })),
|
||
frozen: 2,
|
||
hint,
|
||
};
|
||
}
|
||
|
||
export const buildLabor: TabBuild = ({ sheets, payload }) =>
|
||
priceList(
|
||
sheets.labor || [],
|
||
"단가(원/인)",
|
||
`노임 = 굳힌 단가(${madeText(payload)}) · M01 인력 · 바꾸려면 [단가 갱신]`,
|
||
);
|
||
|
||
export const buildExpense: TabBuild = ({ sheets, payload }) =>
|
||
priceList(
|
||
sheets.expense || [],
|
||
"기준가(천원)",
|
||
`기계 손료 기준가 = 굳힌 단가(${madeText(payload)}) · M01 기계 가격 · 바꾸려면 [단가 갱신]`,
|
||
);
|
||
|
||
function madeText(payload: Row): string {
|
||
return payload.book?.made ? `${String(payload.book.made).slice(0, 10)} 굳힘` : "아직 안 굳힘";
|
||
}
|
||
|
||
export const buildBasis: TabBuild = ({ sheets }): TabTable => {
|
||
const basis: Row = sheets.basis || { fx: [], drivers: [] };
|
||
const rows: DataRow[] = [
|
||
...basis.fx.map((f: Row) => ({
|
||
part: "1. 환율",
|
||
key: f.key,
|
||
name: f.name,
|
||
spec: f.date,
|
||
unit: f.unit,
|
||
day: null,
|
||
factor: "",
|
||
value: f.value,
|
||
})),
|
||
...basis.drivers.map((d: Row) => ({
|
||
part: "2. 인건비",
|
||
key: d.key,
|
||
name: d.name,
|
||
spec: `직종 ${d.key}`,
|
||
unit: "원/시간",
|
||
day: d.day,
|
||
factor: d.factor,
|
||
value: d.hour,
|
||
labor_key: d.labor_key,
|
||
})),
|
||
];
|
||
return {
|
||
columns: [
|
||
text("part", "구분", 90),
|
||
text("name", "명칭", 160),
|
||
text("spec", "규격 · 기준일", 120),
|
||
text("unit", "단위", 80),
|
||
money("day", "일당(원)"),
|
||
text("factor", "시간 환산", 160),
|
||
money("value", "값", undefined, 2),
|
||
],
|
||
rows,
|
||
frozen: 2,
|
||
hint: "운전사 시간 노임 = ROUNDDOWN(일당 × 1/8 × 16/12 × 25/20, 0)(M01 CC000336 · 건협기술-2230) · 환율 = M01 한국은행 · 인건비 줄 누름 = 노무비목록표",
|
||
link: (i) => {
|
||
const row = rows[i];
|
||
return row?.labor_key ? { tab: "labor", key: String(row.labor_key) } : null;
|
||
},
|
||
};
|
||
};
|
||
|
||
const SUPPLY_NEXT: Record<string, string | null> = { "": "관급", 관급: "별도", 별도: null };
|
||
|
||
/** 고를 출처 돌림 — 자동 → 값 있는 출처 차례 → 자동 */
|
||
function nextPick(r: Row): string | null {
|
||
const have = SOURCES.filter((s) => (r.prices[s] ?? 0) > 0);
|
||
return have[have.indexOf(r.pick) + 1] ?? null;
|
||
}
|
||
|
||
export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => {
|
||
const rows: Row[] = sheets.compare || [];
|
||
const out: DataRow[] = rows.map((r) => {
|
||
const files: string[] = (user.materials || {})[r.key]?.files || [];
|
||
const row: DataRow = {
|
||
no: r.no,
|
||
key: r.key,
|
||
name: r.name,
|
||
spec: r.spec,
|
||
unit: r.unit,
|
||
lowest: r.lowest,
|
||
applied: r.applied,
|
||
applied_from: r.pick ? `${r.pick} 고름` : (r.applied_from ?? ""),
|
||
supply: r.supply ?? "",
|
||
files: files.length ? `${files.length} 파일` : "첨부",
|
||
};
|
||
for (const s of SOURCES) {
|
||
row[`p:${s}`] = r.prices[s];
|
||
row[`g:${s}`] = r.pages[s] ?? "";
|
||
}
|
||
return row;
|
||
});
|
||
const mine = (key: string): Row => ({ ...((user.materials || {})[key] || {}) });
|
||
const put = (key: string, cell: Row): void => {
|
||
const materials = { ...(user.materials || {}) };
|
||
if (Object.keys(cell).length) materials[key] = cell;
|
||
else delete materials[key];
|
||
setUser({ ...user, materials });
|
||
};
|
||
return {
|
||
columns: [
|
||
qty("no", "번호", 0),
|
||
text("name", "품명", 180),
|
||
text("spec", "규격", 140),
|
||
text("unit", "단위", 56),
|
||
...SOURCES.flatMap((s) => [money(`p:${s}`, "단가", [s]), text(`g:${s}`, "면", 64, [s])]),
|
||
money("lowest", "최저"),
|
||
money("applied", "적용 단가", ["적용"]),
|
||
text("applied_from", "출처", 80, ["적용"]),
|
||
text("supply", "관급 · 별도", 80),
|
||
text("files", "견적서", 72),
|
||
],
|
||
rows: out,
|
||
frozen: 2,
|
||
hint: "적용 단가 = 출처 중 최저(0 · 빈칸 뺌 · 견적 포함) 자동 · 칸에 넣으면 사용자 값 · 지우면 다시 최저 · 「출처」 칸 누름 = 고를 출처 돌림(값 있는 출처 · 견적이 높아도 고름) · 견적 단가 · 면 = 수기 입력 · 「관급 · 별도」 칸 누름 = 없음 → 관급 → 별도 · 「견적서」 칸 누름 = 파일 올리기 · 보기",
|
||
editable: (_i, key) => key === "applied" || key === "p:견적" || key === "g:견적",
|
||
onEdit: (i, key, value) => {
|
||
const row = out[i];
|
||
if (!row) return;
|
||
const cell = mine(String(row.key));
|
||
if (key === "g:견적") {
|
||
if (value.trim()) cell.quote_page = value.trim();
|
||
else delete cell.quote_page;
|
||
return put(String(row.key), cell);
|
||
}
|
||
const v = parseNumber(value);
|
||
if (v === undefined) return;
|
||
const field = key === "applied" ? "applied" : key === "p:견적" ? "quote" : null;
|
||
if (!field) return;
|
||
if (v === null) delete cell[field];
|
||
else cell[field] = v;
|
||
if (field === "applied") delete cell.pick; // 손 수 = 고른 출처를 풂
|
||
put(String(row.key), cell);
|
||
},
|
||
onCell: (i, key) => {
|
||
const row = out[i];
|
||
const r = rows[i];
|
||
if (!row || !r) return;
|
||
const cell = mine(String(row.key));
|
||
if (key === "files") {
|
||
const label = `${r.name} ${r.spec}`.trim();
|
||
return openQuotes(r.key, label, (names) => {
|
||
const now = mine(r.key);
|
||
if (JSON.stringify(now.files || []) === JSON.stringify(names)) return;
|
||
if (names.length) now.files = names;
|
||
else delete now.files;
|
||
put(r.key, now);
|
||
});
|
||
}
|
||
if (key === "applied_from") {
|
||
const pick = nextPick(r);
|
||
if (pick) cell.pick = pick;
|
||
else delete cell.pick;
|
||
return put(String(row.key), cell);
|
||
}
|
||
if (key !== "supply") return;
|
||
const next = SUPPLY_NEXT[String(row.supply ?? "")] ?? null;
|
||
if (next) cell.supply = next;
|
||
else delete cell.supply;
|
||
put(String(row.key), cell);
|
||
},
|
||
};
|
||
};
|
||
|
||
/** 쓰는 곳(183-1) — 자재 키 → 「대가 n호표」 · 「산근 n호표」 · 「중기 n호표」 · 「내역 자재대」
|
||
* (블록 재료 줄 · 설계내역서 자재 항목 · 표시 잇기만 · 셈 아님) */
|
||
function usesOf(sheets: Row): Map<string, string[]> {
|
||
const out = new Map<string, string[]>();
|
||
const add = (key: unknown, label: string): void => {
|
||
if (!key) return;
|
||
const list = out.get(String(key)) ?? [];
|
||
if (!list.includes(label)) out.set(String(key), [...list, label]);
|
||
};
|
||
const scan = (blocks: Row[] | undefined, label: (b: Row, n: number) => string): void =>
|
||
(blocks || []).forEach((b, n) =>
|
||
(b.lines || []).forEach((ln: Row) => ln.kind === "material" && add(ln.ref?.key, label(b, n))),
|
||
);
|
||
scan(sheets.daega_blocks, (b) => `대가 ${b.no}호표`);
|
||
scan(sheets.sg, (b) => `산근 ${b.no}호표`);
|
||
scan(sheets.machine_blocks, (_b, n) => `중기 ${n + 1}호표`);
|
||
for (const r of sheets.bill?.rows || [])
|
||
if (r.kind === "item" && r.ref?.tab === "material") add(r.ref.key, "내역 자재대");
|
||
return out;
|
||
}
|
||
|
||
export const buildMaterial: TabBuild = ({ sheets }): TabTable => {
|
||
const uses = usesOf(sheets);
|
||
const rows: DataRow[] = (sheets.material || []).map((r: Row) => ({
|
||
...r,
|
||
supply: r.supply ?? "",
|
||
uses: (uses.get(r.key) ?? []).join(" · ") || (r.origin === "B08" ? "B08 총괄" : ""),
|
||
}));
|
||
return {
|
||
columns: [
|
||
qty("no", "호표", 0),
|
||
text("name", "명칭", 200),
|
||
text("spec", "규격", 160),
|
||
text("unit", "단위", 56),
|
||
qty("qty", "수량", 3),
|
||
money("price", "단가"),
|
||
money("amount", "총액"),
|
||
text("supply", "관급 · 별도", 80),
|
||
text("uses", "비고(쓰는 곳)", 240),
|
||
],
|
||
rows,
|
||
total: rows.length
|
||
? { name: "계", amount: rows.reduce((sum, r) => sum + (Number(r.amount) || 0), 0) }
|
||
: null,
|
||
frozen: 2,
|
||
hint: `줄 = 연결된 일위대가 · 산근 · 중기가 쓰는 자재 + B08 총괄 줄 · 관급 · 별도 줄 · 수량 = 설계내역서 × 호표 역전개 총수량(재료비수량금액집계표와 같음 · B08 할증 값과 다를 수 있음) · 단가 = 자재단가대비표 적용 단가 · 총액 = ROUND(수량 × 단가, 0) · 계 = 빈칸 뺀 합 · 비고 = 쓰는 곳 · ${LINK_HINT}`,
|
||
link: (i) => (rows[i] ? { tab: "compare", key: String(rows[i].key) } : null),
|
||
};
|
||
};
|
||
|
||
export const buildMachine: TabBuild = ({ sheets }): TabTable => {
|
||
const rows: DataRow[] = (sheets.machine || []).map((r: Row) => ({ ...r }));
|
||
return {
|
||
columns: [
|
||
qty("no", "호표", 0),
|
||
text("name", "명칭", 200),
|
||
text("spec", "규격", 120),
|
||
text("unit", "단위", 56),
|
||
money("sum", "합계"),
|
||
money("labor", "노무비"),
|
||
money("material", "재료비"),
|
||
money("expense", "경비"),
|
||
],
|
||
rows,
|
||
frozen: 2,
|
||
hint: `시간당 = 중기사용료 블록 합계 · ${LINK_HINT}`,
|
||
link: (i) => (rows[i] ? { tab: "machine_blocks", key: String(rows[i].key) } : null),
|
||
};
|
||
};
|
||
|
||
const KIND_TAB: Record<string, TabId> = {
|
||
expense: "expense",
|
||
labor: "basis",
|
||
material: "compare",
|
||
};
|
||
|
||
export const buildMachineBlocks: TabBuild = ({ sheets }): TabTable => {
|
||
const rows: DataRow[] = [];
|
||
const links: Array<{ tab: TabId; key: string } | null> = [];
|
||
(sheets.machine_blocks || []).forEach((b: Row, n: number) => {
|
||
rows.push({
|
||
key: b.key,
|
||
name: `제 ${n + 1} 호표 ${b.name}`,
|
||
no: n + 1,
|
||
title: b.name, // 171-3 묶음 카드 머리
|
||
spec: b.spec,
|
||
unit: b.unit,
|
||
});
|
||
links.push(null);
|
||
for (const ln of b.lines) {
|
||
const at = (part: string): number | null => (ln.kind === part ? ln.price : null);
|
||
const amt = (part: string): number | null => (ln.kind === part ? ln.amount : null);
|
||
rows.push({
|
||
key: `${b.key}|${ln.name}`,
|
||
name: ` ${ln.name}`,
|
||
spec: ln.spec,
|
||
qty: ln.qty,
|
||
unit: ln.unit,
|
||
sum_price: ln.price,
|
||
sum: ln.amount,
|
||
labor_price: at("labor"),
|
||
labor: amt("labor"),
|
||
material_price: at("material"),
|
||
material: amt("material"),
|
||
expense_price: at("expense"),
|
||
expense: amt("expense"),
|
||
});
|
||
links.push(ln.ref?.key ? { tab: KIND_TAB[ln.kind] ?? "compare", key: ln.ref.key } : null);
|
||
}
|
||
rows.push({
|
||
key: `${b.key}|합계`,
|
||
name: " 합 계",
|
||
sum: b.sum,
|
||
labor: b.labor,
|
||
material: b.material,
|
||
expense: b.expense,
|
||
});
|
||
links.push(null);
|
||
});
|
||
const pair = (key: string, label: string) => [
|
||
money(`${key}_price`, "단가", [label], 1),
|
||
money(key, "금액", [label], 1),
|
||
];
|
||
return {
|
||
columns: [
|
||
text("name", "명칭", 220),
|
||
text("spec", "규격", 120),
|
||
qty("qty", "수량"),
|
||
text("unit", "단위", 56),
|
||
...pair("sum", "합계"),
|
||
...pair("labor", "노무비"),
|
||
...pair("material", "재료비"),
|
||
...pair("expense", "경비"),
|
||
],
|
||
rows,
|
||
frozen: 1,
|
||
hint: "손료 = 기준가(천원) × 손료계수 ÷ 10,000 · 운전사 = 인 × 시간 노임 · 연료 = ℓ/hr × 적용 단가 · 잡품 = 연료 금액 × % · 줄 ROUNDDOWN 1 · 비목 합 ROUNDDOWN 0 · 줄 누름 = 단가를 낸 탭",
|
||
link: (i) => links[i] ?? null,
|
||
};
|
||
};
|