- 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>
378 lines
17 KiB
TypeScript
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,
|
|
};
|
|
}
|