- 탭 = 기번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
290 lines
9.8 KiB
TypeScript
290 lines
9.8 KiB
TypeScript
/* =============================================================================
|
||
* 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,
|
||
};
|
||
}
|