Files
Aislo/B09_Estimation/B09_Estimation_Calc_Labor.ts
T
eomsangdonandClaude Opus 5.5 82a3cb5e7d feat(b09): 호표 갈래 뒤집기 — 일위대가화 줄 = 대가 · 단가산출 줄 = 산근(155-3 · 155-4)
- 일위대가목록 = 구조물 조합 + 사전조합 + 일위대가화(승격 · 옛 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
2026-10-09 19:20:39 +09:00

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,
};
}