Files
Aislo/B09_Estimation/B09_Estimation_Calc_Labor.ts
eomsangdonandClaude Opus 5.5 d8834c1d63 feat(792-4): B09 일위대가 대가 블록이 엔진 새 의미를 따름 — 다섯 길 같은 금액 · 가져온 식 SK 내역 = 엑셀
- 새 B09_Estimation_Labor_Rule: 새 의미 로직만 대가 블록 줄 `place`(줄 자리) · `out`(합밖 · 제외 단계) · 덧줄 = 수 하나 × 밑 합 꼴이면 `rows:` % 줄(줄.'이름' · 앞 덧줄 · 번지 · 단계 · 비목 합 · 793-2a 괄호 꼴) · 이름 없는 수 식 덧줄 = 고정 줄 · 블록 `rule` {끝전, 조정, 끝수} · 다른 꼴 = 빈 줄 그대로(틀린 값 대신 빈칸)
- Labor_Source: 품셈 블록에 표지 · 단가산출 블록이 줄을 바꿔도 표지 이어 받음(657 줄)
- Calc_Labor .py · .ts 짝: 줄 금액 `cut`(place) · `out` 줄은 비목 합 · part: 밑에서 뺌 · 블록 비목 합 `settle`(큰나머지 → 끝수 → 조정 → 원 버림 · master_cut.settle 차례) · 옛 블록 = 표지 없음 = 전과 같음
- 산근 글 로직 부르기 줄(중기 세 줄 · 하위 산근) 금액도 줄 자리 규칙(792-3 남은 것)
- test_792_3: 다섯째 길 B09 대가(SC → 굳힌 SK 수량 1 → 내역 단가 · 파이썬 = TS 거울) · 실무 봉화 D00102 · 북부청임도 18 을 793-2 길(임시 층)로 가져온 식 SK → 내역 단가 = 엑셀
- test_712_3 낡은 기대: 표 비율 0.78 → 단위 수 1,601 이상 · 0.76 — 782-1 · 782-2 · 782-3 계수 표 94 가 로직에 붙음(그중 단위 기준 25 · 시공능력 · 적용기준 계수 = 단위 기준 없음) · 712-3 때 2,023 중 1,601 그대로 · 마스터 자료 안 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:26:03 +09:00

358 lines
16 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 — 내역 단가 · 부른 대가로는 안 넘김 ·
* 줄 0 개 블록 = 빈 줄 하나(NO_LINES) · 줄 ask(723-4 SC 안 줄 수량 없음) = 수량이 비면 why
* 고친 칸 덮개(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}
* 붙인 줄(705-2) = 덮개 @add [블록 줄 fixed · pct · cid @add|…] — 그 cover 블록 끝에 더함(복사본 앞 · 뒤 · 같은 cid 한 번)
* 새 의미 로직 블록(792-4) = 줄 place {방법, 자리}(그 끝수) · 줄 out(비목 합 · part: 밑에서 뺌) ·
* 블록 rule {끝전 큰나머지, 끝수, 조정}(settle · 짝 `.py` settle)
* ========================================================================== */
import {
gap,
num,
roundHalf,
rounddown,
roundup,
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";
const NO_LINES = "구성 줄 없음"; // 줄 0 개 일위대가의 빈 줄 이름(파이썬 짝)
export const COPY = "@copy";
/** 붙인 줄 덮개 열쇠(705-2) */
export const ADD = "@add";
const WHY: Record<string, string> = {
labor: "노임 단가 없음",
material: "자재 적용 단가 없음",
machine: "중기 시간 단가 없음",
daega: "부른 대가 합계 없음",
sangeun: "산근 단가 없음(145-1)",
};
type Parts = Record<(typeof PARTS)[number], Num>;
type Part = (typeof PARTS)[number];
const COST: Record<string, Part> = { 노무비: "labor", 재료비: "material", 경비: "expense" };
const WAYS: Record<string, (v: number, p?: number) => number> = {
버림: rounddown,
올림: roundup,
반올림: roundHalf,
};
/** 줄 금액 끝수 — place {방법, 자리}(792-4) · 없으면 ROUNDDOWN 1(짝 `cut`) */
export function cut(value: number, place: Row | null | undefined): number {
if (!place) return rounddown(value, 1);
return WAYS[place.방법](value, Number(place.자리 || 0));
}
/** 블록 비목 합 — 비목마다 ROUNDDOWN 0 · rule(792-4) = 큰나머지 → 끝수(대상 계) → 조정 → 원 버림
* (짝 `settle`) */
export function settle(
raw: Record<Part, number>,
rule: Row | null | undefined,
): Record<Part, number> {
const parts = { ...raw };
for (const p of PARTS) parts[p] = rounddown(raw[p], 0);
if (!rule) return parts;
const sum = (o: Record<Part, number>): number => PARTS.reduce((acc, p) => acc + o[p], 0);
if (rule.끝전 === "큰나머지") {
const rest = rounddown(sum(raw), 0) - sum(parts);
const order = [...PARTS].sort(
(a, b) => parts[a] - raw[a] - (parts[b] - raw[b]) || PARTS.indexOf(b) - PARTS.indexOf(a),
);
for (const p of order.slice(0, rest)) parts[p] += 1;
}
const end: Row | undefined = rule.끝수;
if (end && typeof end === "object" && end.대상 === "계")
for (const p of PARTS) parts[p] = cut(parts[p], end);
const adjust: Row = rule.조정 || {};
for (const [name, rate] of Object.entries(adjust.배율 || {})) parts[COST[name]] *= Number(rate);
if (adjust.옮김) {
const whole = sum(parts);
for (const p of PARTS) parts[p] = p === COST[adjust.옮김] ? whole : 0;
}
for (const p of PARTS) parts[p] = rounddown(parts[p], 0);
return parts;
}
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:이름 그 줄 ·
* bundle:labor|material|machine 그 종류 줄(+ 그 이음 고정 줄) 합 · rows:0,2 블록 그 차례 줄(src cid) 합
* (778-4 · 짝 `.py` `_base`) */
function pctBase(spec: string, lines: Row[], src: Row[]): Num {
const at = spec.indexOf(":");
const kind = spec.slice(0, at);
const name = spec.slice(at + 1);
const whole = (ln: Row): Num => total(PARTS.map((p) => ln.amounts[p]));
if (kind === "part") return total(lines.filter((ln) => !ln.out).map((ln) => ln.amounts[name]));
if (kind === "rows") {
// `3@labor` = 그 줄 그 비목 몫
const out: Num[] = [];
for (const one of new Set(name.split(","))) {
const [at, part] = one.split("@");
if (!/^\d+$/.test(at) || Number(at) >= src.length) continue;
const cid = src[Number(at)].cid;
for (const ln of lines)
if (ln.cid === cid) out.push(part ? (ln.amounts[part] ?? null) : whole(ln));
}
return total(out);
}
if (kind === "bundle")
return total(
lines.filter((ln) => ln.kind === name || (ln.kind === "fixed" && ln.link?.[name])).map(whole),
);
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 : cut(p * qty, line.place);
}
const ref = line.key ? { ref: { key: line.key } } : {}; // 집계표(145-5) 이음
const ask: Row = qty === null && line.ask ? { why: line.ask } : {}; // 723-4
lines.push({ ...line, ...ref, ...ask, qty, prices, amounts, ...extra });
if (qty === null || PARTS.some((part) => prices[part] === null)) {
const why =
line.why || ask.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, b.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 : cut((base * pct) / 100, line.place);
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 && !ln.out);
const raw = { labor: 0, material: 0, expense: 0 };
for (const part of PARTS) raw[part] = known.reduce((acc, ln) => acc + ln.amounts[part], 0);
const parts = settle(raw, b.rule);
const blankNames = lines.length
? lines.filter((ln) => ln.amount === null).map((ln) => ln.name || "")
: [NO_LINES]; // 줄 0 개 일위대가 = 금액 0 이 아니라 모름(빈 줄)
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;
}
/** 붙인 줄(705-2) — 블록 cover 의 @add 줄을 끝에 더함(이미 있는 cid 는 뺌) */
function withAdded(blocks: Row[], covers: Row): Row[] {
return blocks.map((b) => {
const add: unknown = (covers[b.cover] || {})[ADD];
const have = new Set((b.lines as Row[]).map((ln) => ln.cid));
const more = Array.isArray(add) ? (add as Row[]).filter((ln) => !have.has(ln.cid)) : [];
return more.length ? { ...b, lines: [...b.lines, ...more] } : b;
});
}
/** 대가 탭 둘 — 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[] = withAdded(
withCopies(withAdded((daega || {}).blocks || [], covers), covers),
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) && cid !== ADD)
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,
};
}