Files
Aislo/B09_Estimation/B09_Estimation_Calc_Labor.ts
T
eomsangdonandClaude Opus 5.5 a24e0d622c feat(M02·B09 고정 줄): 일위대가 줄 「고정」 갈래 · B09 대가 fixed 줄 · 이음 단가 · 집계표 빠짐 수 (PLAN 705-1)
- M02 일위대가 줄 {고정: 이름 · 규격 · 단위 · 단가 · 이음(노임 · 자재 · 기계) · 출처} — 정리 · 셈(새 파일 M02_Labor_Fixed) · 줄 가름 짝 TS lineId
- 사본 단가 묶음 · B09 단가 책 수요에 이음 키
- B09 대가 줄 fixed — 이은 비목만 단가 책 값 · 못 이음 = 줄 단가 · 덮개 먹음 · 빈칸 안 됨(파이썬 · TS 짝)
- 집계표 — 이은 고정 줄 = 자원 셈 · 이음 없는 줄 = 빠짐 수(summary.fixed · 안내 「고정 줄 n」)
- B09 일위대가표 줄 꼬리표 「고정」 값 넘김
- 시험 test_705_1 12 · test_145_5 응답 칸 목록에 fixed(낡은 단정 고침)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 14:05:47 +09:00

273 lines
12 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
* 고정 줄(705-1) = 줄 단가(own) · 이음(link) 있으면 그 비목만 단가 책 값(노임 = 노무 · 자재 = 재료
* (관급 · 별도 0원) · 기계 = 중기 세 비목) · 책에 없으면 줄 단가 그대로 · 빈칸 안 됨
* 비고 = 대가 줄 「대가n」 · 산근 줄 「산근 n호표」(산근 목록 번호)
* 줄 금액 ROUNDDOWN(단가 × 수량, 1) · 블록 비목 합 ROUNDDOWN(Σ, 0) · 합계 = 세 비목 합
* 관급 · 별도계상 자재 = 대가 안 0원(자재대 · 관급 단락에서 셈)
* 빈칸 = 원천이 없으면 null · 「연결 없음」 목록(gaps)
* 빈 줄(166-6) = 블록 합 = 아는 줄 합 + blank n · blank_names — 내역 단가 · 부른 대가로는 안 넘김
* 고친 칸 덮개(702-2) = 사용자 값 daega_cover {블록 cover: {줄 cid: {qty · labor · material · expense}}} —
* 있는 칸만 셈 값 대신 · 줄 base(품셈 값) · covered(고친 칸) · 짝 잃은 덮개 = daega_orphans(알림만)
* 복사본(703-2) = 덮개 K:… 의 @copy {from · name · spec} — 원본 cover 블록을 본뜬 새 호표
* (id DG:K:… · 번호 = 끝 · 줄 · 기본값 = 원본 · 덮개 = 제 것) · 원본 없음 = 짝 잃음 {cover, cid: @copy}
* ========================================================================== */
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 COVER_KEYS = ["qty", ...PARTS] as const;
const TAB = "dg";
export const COPY = "@copy";
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] = row.blank ? null : num(row[part]); // 빈 줄 대가 = 빈칸
} else if (kind === "fixed") return fixedPrices(line, books);
else return blanks(); // blank — 못 펼친 줄
return out;
}
/** 고정 줄 단가(705-1) — 줄 단가 위에 이은 비목만 단가 책 값(짝 `_fixed`) */
function fixedPrices(line: Row, books: Row): Parts {
const own: Row = line.own || {};
const link: Row = line.link || {};
const out = zeros();
for (const part of PARTS) out[part] = num(own[part]) ?? 0;
const labor: Row | undefined = link.labor ? books.labor[link.labor] : undefined;
if (labor && num(labor.price) !== null) out.labor = num(labor.price);
const row: Row | undefined = link.material ? books.material[link.material] : undefined;
const supplied = !!row && (SUPPLY as readonly string[]).includes(row.supply);
if (row && (supplied || num(row.price) !== null)) out.material = supplied ? 0 : num(row.price);
const machine: Row | undefined = link.machine ? books.machine[link.machine] : undefined;
if (machine && !machine.blank)
for (const part of PARTS) if (num(machine[part]) !== null) out[part] = num(machine[part]);
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));
}
/** 덮개 한 줄 — [수량, 단가, 덧칸] · 덧칸 = base(품셈 값) · covered(고친 칸 차례) */
function cover(line: Row, own: Row, qty0: Num, prices0: Parts): [Num, Parts, Row] {
const mine: Row = own[line.cid] || {};
const keys = COVER_KEYS.filter((k) => k in mine);
if (!keys.length) return [qty0, prices0, {}];
const extra = { base: { qty: qty0, ...prices0 }, covered: keys };
const prices = { ...prices0 };
for (const p of PARTS) if (p in mine) prices[p] = num(mine[p]);
return ["qty" in mine ? num(mine.qty) : qty0, prices, extra];
}
function block(b: Row, books: Row, sums: Record<string, Row>, gaps: Gap[], own: Row): Row {
const lines: Row[] = [];
for (const line of b.lines) {
if (line.kind === "pct") continue;
const [qty, prices, extra] = cover(line, own, num(line.qty), 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, ...extra });
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 mine: Row = own[line.cid] || {};
const pct = "qty" in mine ? num(mine.qty) : num(line.qty);
const extra = "qty" in mine ? { base: { qty: num(line.qty) }, covered: ["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, ...extra });
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 known = lines.filter((ln) => ln.amount !== null);
const parts = zeros();
for (const part of PARTS)
parts[part] = rounddown(
known.reduce((acc, ln) => acc + ln.amounts[part], 0),
0,
);
const blankNames = lines.filter((ln) => ln.amount === null).map((ln) => ln.name || "");
return {
id: b.id,
key: b.id,
kind: b.kind,
name: b.name,
spec: b.spec,
...(b.spec_raw ? { spec_raw: b.spec_raw } : {}), // 157-4 풍선(파이썬 짝)
unit: b.unit,
cite: b.cite,
cover: b.cover ?? null,
lines,
...parts,
sum: PARTS.reduce((acc, p) => acc + (parts[p] as number), 0),
blank: blankNames.length,
blank_names: blankNames,
};
}
/** 복사본(703-2) — 덮개 차례대로 @copy 마다 원본 블록을 본떠 끝에 더함(복사본의 복사본 됨) */
function withCopies(blocks: Row[], covers: Row): Row[] {
const out = [...blocks];
for (const [key, lines] of Object.entries(covers)) {
const meta: Row | undefined = (lines || {})[COPY];
if (!meta || typeof meta !== "object") continue;
const src = out.find((b) => b.cover === meta.from);
if (!src) continue;
const spec = "spec" in meta ? meta.spec : src.spec;
out.push({ ...src, id: `DG:${key}`, cover: key, name: meta.name || src.name, spec });
}
return out;
}
/** 대가 탭 둘 — daega_blocks(일위대가표) · daega(목록표) · daega_owners + 바탕 gaps 이어 붙임 */
export function computeLabor(
daega: Row | null | undefined,
sheets: Row,
sangeun: Row = {},
covers: Row = {},
): Row {
covers = covers || {};
const blocks: Row[] = withCopies((daega || {}).blocks || [], covers);
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, covers[byId[bid].cover] || {});
};
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,
...(b.spec_raw ? { spec_raw: b.spec_raw } : {}),
unit: b.unit,
sum: b.sum,
labor: b.labor,
material: b.material,
expense: b.expense,
blank: b.blank,
blank_names: b.blank_names,
}));
const cids = new Map<unknown, Set<string>>();
for (const b of blocks) {
const got = cids.get(b.cover) ?? new Set<string>();
for (const ln of b.lines) got.add(ln.cid);
cids.set(b.cover, got);
}
const orphans: Row[] = [];
for (const [key, lines] of Object.entries(covers))
for (const cid of Object.keys(lines || {}))
if (!cids.get(key)?.has(cid) && !(cid === COPY && `DG:${key}` in byId))
orphans.push({ cover: key, cid });
return {
daega_blocks: outBlocks,
daega: listing,
daega_owners: { ...((daega || {}).owners || {}) },
daega_orphans: orphans,
gaps: [...(sheets.gaps || []), ...gaps],
};
}
/** 내역서(145-3)가 읽는 대가 단가 — 구조물 id → 호표 · 비목 단가 · 없으면 null ·
* 빈 줄 있는 블록 = 비목 단가 빈칸(166-6 · 아는 줄 합은 안 넘김) */
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.blank ? null : row.labor,
material: row.blank ? null : row.material,
expense: row.blank ? null : row.expense,
sum: row.blank ? null : row.sum,
};
}