- 일위대가목록 = 구조물 조합 + 사전조합 + 일위대가화(승격 · 옛 SP) 줄 · 단가산출 줄 = 산근 줄 - 산근 줄 수량 = 수량 그대로(배 ≠ 1 로직 금액 1/배 흠) · 대가 줄만 ÷ 배 - 품셈 블록 로직 줄 판정 = 산근 풀이와 같은 call_of(기계경비 = 중기 · 그 밖 = 하위 산근) - 비고 「산근 n호표」(Calc_Labor 파이썬 · TS 짝) · 산근 줄 누름 = 단가산출근거 탭 - 산근목록 원천 = 단가산출 줄 + 사전조합 안 줄 + 일위대가화 줄 하위 로직(노무 · 자재 · 중기 줄 제외) - 산근 자재 자리표시 키(MO:경유:…) = 자재단가대비표 키로 - 시험: 145-1 다섯 · 145-2 셋 새 갈래로(머리에 근거 한 줄) · 새 test_155_b09_sources Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X
191 lines
7.5 KiB
TypeScript
191 lines
7.5 KiB
TypeScript
/* =============================================================================
|
|
* B09_Estimation_Calc_Labor.ts
|
|
* B09 일위대가표 · 일위대가목록표 셈(PLAN 145-2) — 화면 없음 · 저장 안 함.
|
|
*
|
|
* ⚠ 짝 = `B09_Estimation_Calc_Labor.py`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 ·
|
|
* 거울 시험 `resources/tester/test_145_2_b09_labor_calc.py`.
|
|
* 조작 중은 이 파일이 바로 셈 · [저장] 때 서버가 파이썬 짝으로 정본을 다시 셈.
|
|
*
|
|
* 노무 줄 = 노무비목록표 · 재료 줄 = 재료비목록표 · 중기 줄 = 중기목록표 · 대가 줄 = 부른 블록 합계 ·
|
|
* 산근 줄 = 145-1 산근 단가 · % 줄 = 밑수(노무 줄 금액 합) × % / 100
|
|
* 비고 = 대가 줄 「대가n」 · 산근 줄 「산근 n호표」(산근 목록 번호)
|
|
* 줄 금액 ROUNDDOWN(단가 × 수량, 1) · 블록 비목 합 ROUNDDOWN(Σ, 0) · 합계 = 세 비목 합
|
|
* 관급 · 별도계상 자재 = 대가 안 0원(자재대 · 관급 단락에서 셈)
|
|
* 빈칸 = 원천이 없으면 null · 그 칸을 쓰는 합도 null · 「연결 없음」 목록(gaps)
|
|
* ========================================================================== */
|
|
|
|
import {
|
|
gap,
|
|
num,
|
|
rounddown,
|
|
SUPPLY,
|
|
total,
|
|
type Gap,
|
|
type Num,
|
|
type Row,
|
|
} from "./B09_Estimation_Calc_Base";
|
|
|
|
const PARTS = ["labor", "material", "expense"] as const;
|
|
const TAB = "dg";
|
|
const WHY: Record<string, string> = {
|
|
labor: "노임 단가 없음",
|
|
material: "자재 적용 단가 없음",
|
|
machine: "중기 시간 단가 없음",
|
|
daega: "부른 대가 합계 없음",
|
|
sangeun: "산근 단가 없음(145-1)",
|
|
};
|
|
|
|
type Parts = Record<(typeof PARTS)[number], Num>;
|
|
|
|
const zeros = (): Parts => ({ labor: 0, material: 0, expense: 0 });
|
|
const blanks = (): Parts => ({ labor: null, material: null, expense: null });
|
|
|
|
function linePrices(line: Row, books: Row, sums: Record<string, Row>): Parts {
|
|
const { kind, key } = line;
|
|
const out = zeros();
|
|
if (kind === "labor") out.labor = num((books.labor[key] || {}).price);
|
|
else if (kind === "material") {
|
|
const row: Row = books.material[key] || {};
|
|
// 관급 · 별도계상 자재 = 대가 안 0원(자재대 · 관급 단락에서 셈 — 두 번 안 셈)
|
|
out.material = (SUPPLY as readonly string[]).includes(row.supply) ? 0 : num(row.price);
|
|
} else if (kind === "machine" || kind === "daega" || kind === "sangeun") {
|
|
const src: Row = ({ machine: books.machine, daega: sums, sangeun: books.sangeun } as Row)[kind];
|
|
const row: Row = src[key] || {};
|
|
for (const part of PARTS) out[part] = num(row[part]);
|
|
} else return blanks(); // blank — 못 펼친 줄
|
|
return out;
|
|
}
|
|
|
|
/** % 줄 밑수 — part:비목 그 비목 줄 금액 합 · lines:labor 노무 줄 금액 합 · line:이름 그 줄 */
|
|
function pctBase(spec: string, lines: Row[]): Num {
|
|
const at = spec.indexOf(":");
|
|
const kind = spec.slice(0, at);
|
|
const name = spec.slice(at + 1);
|
|
if (kind === "part") return total(lines.map((ln) => ln.amounts[name]));
|
|
if (kind === "line") {
|
|
const hit = lines.find((ln) => ln.name === name);
|
|
return hit ? total(PARTS.map((p) => hit.amounts[p])) : null;
|
|
}
|
|
return total(lines.filter((ln) => ln.kind === "labor").map((ln) => ln.amounts.labor));
|
|
}
|
|
|
|
function block(b: Row, books: Row, sums: Record<string, Row>, gaps: Gap[]): Row {
|
|
const lines: Row[] = [];
|
|
for (const line of b.lines) {
|
|
if (line.kind === "pct") continue;
|
|
const qty = num(line.qty);
|
|
const prices = linePrices(line, books, sums);
|
|
const amounts = blanks();
|
|
for (const part of PARTS) {
|
|
const p = prices[part];
|
|
amounts[part] = p === null || qty === null ? null : rounddown(p * qty, 1);
|
|
}
|
|
const ref = line.key ? { ref: { key: line.key } } : {}; // 집계표(145-5) 이음
|
|
lines.push({ ...line, ...ref, qty, prices, amounts });
|
|
if (qty === null || PARTS.some((part) => prices[part] === null)) {
|
|
const why = line.why || (qty === null ? "수량 없음" : (WHY[line.kind] ?? "단가 없음"));
|
|
gaps.push(gap(TAB, b.id, `${b.name} · ${line.name || ""}`, why));
|
|
}
|
|
}
|
|
for (const line of b.lines) {
|
|
if (line.kind !== "pct") continue;
|
|
const base = pctBase(line.base || "lines:labor", lines);
|
|
const pct = num(line.qty);
|
|
const prices = zeros();
|
|
const amounts = zeros();
|
|
const part = (line.part || "expense") as (typeof PARTS)[number];
|
|
prices[part] = base;
|
|
amounts[part] = base === null || pct === null ? null : rounddown((base * pct) / 100, 1);
|
|
lines.push({ ...line, qty: pct, prices, amounts });
|
|
if (amounts[part] === null)
|
|
gaps.push(gap(TAB, b.id, `${b.name} · ${line.name}`, "% 밑수 없음"));
|
|
}
|
|
for (const line of lines) {
|
|
line.price = total(PARTS.map((p) => line.prices[p]));
|
|
line.amount = total(PARTS.map((p) => line.amounts[p]));
|
|
}
|
|
const parts = blanks();
|
|
for (const part of PARTS) {
|
|
const s = total(lines.map((ln) => ln.amounts[part]));
|
|
parts[part] = s === null ? null : rounddown(s, 0);
|
|
}
|
|
return {
|
|
id: b.id,
|
|
key: b.id,
|
|
kind: b.kind,
|
|
name: b.name,
|
|
spec: b.spec,
|
|
unit: b.unit,
|
|
cite: b.cite,
|
|
lines,
|
|
...parts,
|
|
sum: total(PARTS.map((p) => parts[p])),
|
|
};
|
|
}
|
|
|
|
/** 대가 탭 둘 — daega_blocks(일위대가표) · daega(목록표) · daega_owners + 바탕 gaps 이어 붙임 */
|
|
export function computeLabor(daega: Row | null | undefined, sheets: Row, sangeun: Row = {}): Row {
|
|
const blocks: Row[] = (daega || {}).blocks || [];
|
|
const index = (rows: Row[] | undefined): Row =>
|
|
Object.fromEntries((rows || []).map((r) => [r.key, r]));
|
|
const books: Row = {
|
|
labor: index(sheets.labor),
|
|
material: index(sheets.material),
|
|
machine: index(sheets.machine),
|
|
sangeun: sangeun || {},
|
|
};
|
|
const byId: Record<string, Row> = Object.fromEntries(blocks.map((b) => [b.id, b]));
|
|
const no: Record<string, number> = Object.fromEntries(blocks.map((b, i) => [b.id, i + 1]));
|
|
const done: Record<string, Row> = {};
|
|
const gaps: Gap[] = [];
|
|
const run = (bid: string, trail: string[]): void => {
|
|
if (bid in done || trail.includes(bid) || !(bid in byId)) return;
|
|
for (const line of byId[bid].lines) if (line.kind === "daega") run(line.key, [...trail, bid]);
|
|
done[bid] = block(byId[bid], books, done, gaps);
|
|
};
|
|
for (const b of blocks) run(b.id, []);
|
|
const outBlocks = blocks.map((b) => {
|
|
const got: Row = { ...done[b.id], no: no[b.id] };
|
|
for (const line of got.lines)
|
|
if (line.kind === "daega" && line.key in no) line.ref_no = `대가${no[line.key]}`;
|
|
for (const line of got.lines) {
|
|
const sgNo = (books.sangeun[line.key] || {}).no;
|
|
if (line.kind === "sangeun" && sgNo != null) line.ref_no = `산근 ${sgNo}호표`; // 155-3 비고
|
|
}
|
|
return got;
|
|
});
|
|
const listing = outBlocks.map((b) => ({
|
|
no: b.no,
|
|
key: b.id,
|
|
kind: b.kind,
|
|
name: b.name,
|
|
spec: b.spec,
|
|
unit: b.unit,
|
|
sum: b.sum,
|
|
labor: b.labor,
|
|
material: b.material,
|
|
expense: b.expense,
|
|
}));
|
|
return {
|
|
daega_blocks: outBlocks,
|
|
daega: listing,
|
|
daega_owners: { ...((daega || {}).owners || {}) },
|
|
gaps: [...(sheets.gaps || []), ...gaps],
|
|
};
|
|
}
|
|
|
|
/** 내역서(145-3)가 읽는 대가 단가 — 구조물 id → 호표 · 비목 단가 · 없으면 null */
|
|
export function daegaPrice(sheets: Row, owner: string): Row | null {
|
|
const bid = (sheets.daega_owners || {})[owner];
|
|
const row = (sheets.daega || []).find((r: Row) => r.key === bid);
|
|
if (!row) return null;
|
|
return {
|
|
no: `대가${row.no}`,
|
|
key: bid,
|
|
labor: row.labor,
|
|
material: row.material,
|
|
expense: row.expense,
|
|
sum: row.sum,
|
|
};
|
|
}
|