Files
Aislo/B09_Estimation/B09_Estimation_Calc_Base.ts
T
eomsangdonandClaude Opus 5.5 e1d83398d4 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
2026-10-09 15:01:27 +09:00

290 lines
9.8 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_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,
};
}