feat(B09): 원가 바탕 단가 — 뼈대 · 노무 · 경비 · 환율 · 자재 대비 · 중기 탭(144-1~4)

- 탭 = 기번3 공통 엑셀 보이는 탭 차례(목차 · 안내 = 연결 점검) · 탭 하나 = 공용 표 하나 · 호표 칸 누름 = 그 탭 그 줄로 이동
- 저장 = 프로젝트 B09_Estimation/v1(사용자 값 · 굳힌 단가 한 벌) · 결과는 GET 때 서버 셈 · [저장] · [단가 갱신] 길
- 줄 = 수요 따라(연결된 사본 원천 자리 · 기계 → 운전사 · 연료 · B08 총괄 자재) · 연결 없으면 빈칸 + 연결 점검 목록
- 셈 = 파이썬 · TS 짝 + 거울(운전사 시간 노임 · 자재 최저 자동 + 사용자 고침 · 관급 · 별도 · 중기 손료 + 운전사 + 연료 + 잡품 · 엑셀 끝수)
- 공용 표 = 칸 누름 · 줄로 이동(강조) 더함
- 시험 = test_144_b09_skeleton · test_144_b09_base_calc(엑셀 중기 126,000 · 76,436 · 96,829 · 40,659 · 운전사 55,700 · 47,231 · 33,571)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X
This commit is contained in:
2026-10-09 15:01:27 +09:00
co-authored by Claude Opus 5.5
parent c0cf0ab90a
commit e1d83398d4
15 changed files with 2075 additions and 11 deletions
+289
View File
@@ -0,0 +1,289 @@
/* =============================================================================
* B09_Estimation_Calc_Base.ts
* B09 원가 바탕 단가 셈(PLAN 144) — 화면 없음 · 저장 안 함 · import 없음(시험이 node 로 돌림).
*
* ⚠ 짝 = `B09_Estimation_Calc_Base.py`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 ·
* 거울 시험 `resources/tester/test_144_b09_base_calc.py`.
* 조작 중은 이 파일이 바로 셈 · [저장] 때 서버가 파이썬 짝으로 정본을 다시 셈.
*
* 노무비 · 경비목록표 = 굳힌 단가 · 환율및기초자료 = 운전사 시간 노임 ROUNDDOWN(일당 × 분자 ÷ 분모, 0)
* 자재단가대비표 = 출처 중 최저 자동 · 사용자 적용 단가 앞섬 · 관급 · 별도 표시 → 재료비목록표
* 중기사용료 = 손료 + 운전사 + 연료 + 잡품 · 줄 ROUNDDOWN 1 · 비목 합 ROUNDDOWN 0 → 중기목록표
* 빈칸 = 원천이 없으면 null · 그 칸을 쓰는 합도 null · 「연결 없음」 목록(gaps)에 남김
* ========================================================================== */
export type Num = number | null;
export type Row = Record<string, any>;
export interface Gap {
tab: string;
key: string;
name: string;
why: string;
}
/** 떠도는 끝수(부동소수) — 이만큼은 자리 올림 · 버림에서 무시 */
const EPS = 1e-7;
/** 자재단가대비표 출처 칸 차례 — 엑셀 조달 · 물가정보 · 물가자료(같은 값이면 앞 칸) */
export const SOURCES = ["관급", "물가정보", "물가자료", "유통물가", "거래가격", "유가"] as const;
/** 자재 줄 사용자 표시 */
export const SUPPLY = ["관급", "별도"] as const;
function scaled(value: number, places: number, fn: (x: number) => number): number {
const a = Math.abs(value);
let out: number;
if (places >= 0) {
const scale = 10 ** places;
out = fn(a * scale) / scale;
} else {
const unit = 10 ** -places;
out = fn(a / unit) * unit;
}
return value < 0 ? -out : out;
}
/** 엑셀 ROUNDDOWN */
export const rounddown = (value: number, places = 0): number =>
scaled(value, places, (x) => Math.floor(x + EPS));
/** 엑셀 ROUNDUP */
export const roundup = (value: number, places = 0): number =>
scaled(value, places, (x) => Math.ceil(x - EPS));
/** 엑셀 ROUND(반 올림) */
export const roundHalf = (value: number, places = 0): number =>
scaled(value, places, (x) => Math.floor(x + 0.5 + EPS));
export function num(value: unknown): Num {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
/** 합 — 하나라도 빈칸이면 빈칸 */
export function total(values: Num[]): Num {
let out = 0;
for (const v of values) {
if (v === null) return null;
out += v;
}
return out;
}
export const gap = (tab: string, key: string, name: string, why: string): Gap => ({
tab,
key,
name,
why,
});
export function driverHour(day: Num, factor: Row | null | undefined): Num {
if (day === null || !factor || !factor.den) return null;
return rounddown((day * factor.num) / factor.den, 0);
}
const head = (row: Row, no: number): Row => ({
no,
key: row.key,
name: row.name || "",
spec: row.spec || "",
source: row.source || "",
});
function laborSheet(labor: Row[], gaps: Gap[]): Row[] {
return labor.map((row, i) => {
const price = num(row.price);
if (price === null) gaps.push(gap("labor", row.key, row.name, "노임 단가 없음"));
return { ...head(row, i + 1), code: row.code ?? null, unit: row.unit || "인", price };
});
}
function expenseSheet(machines: Row[], gaps: Gap[]): Row[] {
return machines.map((m, i) => {
const price = num(m.price);
if (price === null) gaps.push(gap("expense", m.key, m.name, "기계 기준가 없음"));
return { ...head(m, i + 1), unit: "천원", price };
});
}
export function lowest(prices: Row): [Num, string | null] {
let best: [Num, string | null] = [null, null];
for (const source of SOURCES) {
const v = num(prices[source]);
if (v !== null && v > 0 && (best[0] === null || v < best[0])) best = [v, source];
}
return best;
}
function compareSheet(materials: Row[], user: Row, gaps: Gap[]): Row[] {
return materials.map((m, i) => {
const mine: Row = user[m.key] || {};
const prices: Row = m.prices || {};
const [low, lowFrom] = lowest(prices);
let applied: Num;
let appliedFrom: string | null;
if ("applied" in mine) [applied, appliedFrom] = [num(mine.applied), "사용자"];
else [applied, appliedFrom] = [low, lowFrom];
if (applied === null) gaps.push(gap("compare", m.key, m.name, "적용 단가 없음(출처 없음)"));
const supply = (SUPPLY as readonly string[]).includes(mine.supply) ? mine.supply : null;
const pages: Row = m.pages || {};
return {
...head(m, i + 1),
unit: m.unit || "",
origin: m.origin || "",
prices: Object.fromEntries(SOURCES.map((s) => [s, num(prices[s])])),
pages: Object.fromEntries(SOURCES.map((s) => [s, pages[s] ?? null])),
lowest: low,
lowest_from: lowFrom,
applied,
applied_from: applied !== null ? appliedFrom : null,
supply,
};
});
}
function materialSheet(compare: Row[]): Row[] {
const keep = ["no", "key", "name", "spec", "unit", "supply", "origin"];
return compare.map((row) => ({
...Object.fromEntries(keep.map((k) => [k, row[k]])),
price: row.applied,
}));
}
function line(
name: string,
spec: string,
qty: Num,
unit: string,
kind: string,
price: Num,
ref: Row | null,
pct = false,
): Row {
let amount: Num;
if (price === null || qty === null) amount = null;
else if (pct) amount = rounddown((price * qty) / 100, 1);
else amount = rounddown(price * qty, 1);
return { name, spec, qty, unit, kind, price, amount, ref };
}
function machineBlock(m: Row, drivers: Row, fuels: Row, gaps: Gap[]): Row {
const lines: Row[] = [];
const loss = num(m.loss);
const coef = loss !== null ? loss / 10000 : null;
const price = num(m.price);
lines.push(
line(m.name, m.spec || "", coef, "천원", "expense", price, { tab: "expense", key: m.key }),
);
const crew = num(m.crew);
if (crew) {
const code = String(m.crew_code || "");
const driver: Row = drivers[code] || {};
const hour: Num = driver.hour ?? null;
if (hour === null) gaps.push(gap("machine_blocks", m.key, m.name, "운전사 노임 없음"));
lines.push(
line(driver.name || "운전사", "", crew, "인", "labor", hour, { tab: "basis", key: code }),
);
}
const lph = num(m.fuel_lph);
if (lph) {
const kind = String(m.fuel || "");
const fuel: Row = fuels[kind] || {};
const fprice: Num = fuel.applied ?? null;
if (fprice === null) gaps.push(gap("machine_blocks", m.key, m.name, `연료(${kind}) 단가 없음`));
const fuelLine = line(fuel.name || kind, fuel.spec || "", lph, "ℓ", "material", fprice, {
tab: "compare",
key: fuel.key || "",
});
lines.push(fuelLine);
const misc = num(m.misc_pct);
if (misc)
lines.push(line("잡품", "주연료의 %", misc, "%", "material", fuelLine.amount, null, true));
}
if (price === null || coef === null)
gaps.push(gap("machine_blocks", m.key, m.name, "손료 기준가 · 계수 없음"));
const sums: Record<string, Num> = {};
for (const part of ["labor", "material", "expense"]) {
const s = total(lines.filter((ln) => ln.kind === part).map((ln) => ln.amount));
sums[part] = s === null ? null : rounddown(s, 0);
}
return {
key: m.key,
name: m.name,
spec: m.spec || "",
unit: "시간",
lines,
labor: sums.labor,
material: sums.material,
expense: sums.expense,
sum: total([sums.labor, sums.material, sums.expense]),
};
}
function basisSheet(inputs: Row, laborRows: Row[]): Row {
const factor = inputs.driver_factor;
const byCode: Row = {};
for (const row of laborRows)
if (row.code !== null && row.code !== undefined) byCode[String(row.code)] = row;
const codes: string[] = [];
for (const m of inputs.machines || []) {
const code = m.crew_code ? String(m.crew_code) : null;
if (code && num(m.crew) && !codes.includes(code)) codes.push(code);
}
const drivers = codes.map((code) => {
const row: Row = byCode[code] || {};
const day: Num = row.price ?? null;
return {
key: code,
labor_key: row.key ?? null,
name: row.name || `직종 ${code}`,
day,
factor: (factor || {}).text || "",
hour: driverHour(day, factor),
};
});
const fx = (inputs.fx || []).map((f: Row) => ({
key: f.key,
name: f.name || "",
unit: f.unit || "",
value: num(f.value),
date: f.date || "",
}));
return { fx, drivers };
}
/** 144 탭 일곱 + 「연결 없음」 목록 */
export function computeBase(inputs: Row, user: Row): Row {
const gaps: Gap[] = [];
const laborRows = laborSheet(inputs.labor || [], gaps);
const expense = expenseSheet(inputs.machines || [], gaps);
const compare = compareSheet(inputs.materials || [], user.materials || {}, gaps);
const basis = basisSheet(inputs, laborRows);
const drivers: Row = Object.fromEntries(basis.drivers.map((d: Row) => [d.key, d]));
const byKey: Row = Object.fromEntries(compare.map((row) => [row.key, row]));
const fuels: Row = {};
for (const [kind, key] of Object.entries(inputs.fuel_keys || {}))
if ((key as string) in byKey) fuels[kind] = byKey[key as string];
const blocks = (inputs.machines || []).map((m: Row) => machineBlock(m, drivers, fuels, gaps));
const machine = blocks.map((b: Row, i: number) => ({
no: i + 1,
key: b.key,
name: b.name,
spec: b.spec,
unit: b.unit,
sum: b.sum,
labor: b.labor,
material: b.material,
expense: b.expense,
}));
return {
labor: laborRows,
expense,
basis,
compare,
material: materialSheet(compare),
machine_blocks: blocks,
machine,
gaps,
};
}