Files
Aislo/B09_Estimation/B09_Estimation_Calc_Labor.ts
T
eomsangdonandClaude Opus 5.5 5f2f073902 fix(841-1): 기계 덮음 단가산출 = B09 에 그대로 — SK 사본에 수식 문서 · 블록 합 = M02 SK 셈
- M02_Item_Sangeun.freeze: 굳힌 SK 사본에 원본 「수식」 칸(기계덮음 · 수식 고침이 든 수식 문서)을 같이 담음(브레인 허락 · 두 줄) — 구조물 사본 · 서고 사본 같은 굳히기
- B09 단가산출 블록 합 = M02_Sangeun_Calc.unit(M02 가 일위대가 안 SK 를 셀 때 쓰는 셈) × 배 · 고친 칸 · 붙인 줄 · 재료 표시 바꿈 없을 때만(Supply.sk_money · Calc_Labor py · ts 짝)
- 검증_sub5 실서버: 굴착기 가격 덮음 SK M02 4,560 = B09 4,560(전 3,548) · 옛 사본(수식 없음) = 덮기 전 값 그대로
- 사본 크기: SK 사본 일위대가.json 58 → 95 KB · 일위대가 사본 155 → 238 KB(원본 칸과 수식 겹쳐 담음)
- 지킴 시험 test_841_1_b09_m02_values.py(B09 기계 시간당 다시 셈 없음 · 기계 대가 = 엔진 기준 · 덮음 SK 사본까지)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 16:17:05 +09:00

378 lines
17 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"] as const; // 841-1 단가 고친 칸 걷음 — 품(수량)만
const TAB = "dg";
const NO_LINES = "구성 줄 없음"; // 줄 0 개 일위대가의 빈 줄 이름(파이썬 짝)
export const COPY = "@copy";
/** 붙인 줄 덮개 열쇠(705-2) */
export const ADD = "@add";
const WHY: Record<string, string> = {
labor: "노임 단가 없음(M02 엔진)",
material: "자재 단가 없음(M02 엔진)",
machine: "기계 시간당 없음(M02 엔진)",
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;
let out = zeros();
if (kind === "labor" || kind === "material" || kind === "machine") {
// 841-1 = M02 엔진 단가(`own`) · B09 단가 책 안 읽음
const own: Row | undefined = line.own;
out = blanks();
if (own) for (const part of PARTS) out[part] = part in own ? num(own[part]) : 0;
} else if (kind === "daega" || kind === "sangeun") {
const src: Row = ({ 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) — 줄 단가 그대로 · 이은 자재가 관급 · 별도계상이면 재료 0(짝 `_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 row: Row | undefined = link.material ? books.material[link.material] : undefined;
if (row && (SUPPLY as readonly string[]).includes(row.supply)) out.material = 0;
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]));
// 대가 밖 재료(841-1 `aside`)도 밑수에 — M02 엔진 「재료비의 %」 꼴
const aside = name === "material" ? "aside" : "";
if (kind === "part")
return total(
lines.filter((ln) => !ln.out).map((ln) => (aside && ln[aside]) || 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];
}
/** SK 블록 합을 M02 셈으로 — 고친 칸 · 붙인 줄 없음 · 재료 표시가 M02 자재표시 그대로일 때만 */
function asM02(b: Row, lines: Row[], books: Row, own: Row): boolean {
const supplied = (v: unknown): boolean => (SUPPLY as readonly string[]).includes(v as string);
return (
!!b.m02 &&
!Object.keys(own).length &&
lines
.filter((ln) => ln.kind === "material")
.every((ln) => supplied((books.material[ln.key] || {}).supply) === supplied(ln.mark))
);
}
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);
}
// 관급 · 별도계상 = 대가 안 0원(841-1 대비표 표시 = 사용자 › M02 자재표시)
const supply = (books.material[line.key] || {}).supply;
if (line.kind === "material" && (SUPPLY as readonly string[]).includes(supply)) {
extra.aside = amounts.material;
prices.material = 0;
amounts.material = 0;
}
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);
// 841-1 단가산출 블록 = M02 SK 셈 그대로(덮음 · 수식 고침 · 짝 `_as_m02`)
if (asM02(b, lines, books, own)) for (const p of PARTS) parts[p] = rounddown(b.m02[p], 0);
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,
};
}