Files
Aislo/B09_Estimation/B09_Estimation_Calc_Bill.ts
T

490 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* B09_Estimation_Calc_Bill.ts
* B09 설계내역서 셈(PLAN 145-3) — 화면 없음 · 저장 안 함 · import 는 짝 바탕 셈 하나(시험이 이어 붙임).
*
* ⚠ 짝 = `B09_Estimation_Calc_Bill.py`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 ·
* 거울 시험 `resources/tester/test_145_3_b09_bill.py` · `test_182_b09_bill_level.py`(레벨) · `test_184_2_b09_bill_free.py`(자유 편집).
*
* 공종 나무 = 엑셀 꼴 고정(1 토공사 · 2 구조물공 · 3 운반공 · 4 자재대 · 관급자재대(제외금액)) · 공구 안 나눔
* 항목 = 서버가 B08 수량으로 짠 줄 + 대비표 「별도」(4 자재대) · 「관급」(관급자재대) 자재 + 조달수수료
* 금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 조달수수료 = ROUNDDOWN(순자재대 × 율 ÷ 100, 0)
* 묶음 금액 = 자손 항목 합(빈 줄은 「빈 줄 n」) · 원가계산서로 넘길 합 = sums(빈 줄 있으면 null)
* 단가 = `refPrice(ref)` 하나(dg 대가 목록 · sg 산근 목록 · logic = 서버 sg_hand 산근 · 그 밖 hopyo)
* 자유 편집(184) = `user.bill` v2 덮개 {v: 2, rows: [표 차례 · 고친 칸 · 새 줄], gone: [지운 자동 줄]} ·
* 옛 꼴(qty · 손 줄 after · parent) 그대로 읽음 · 저장 꼴 · 출력 줄 칸(계약) = 짝 .py 머리
* 출력 줄 칸 = kind · key · name · parent · level · no · cost · auto | hand · auto_{칸} · section
* ========================================================================== */
import {
computeBase,
gap,
num,
roundHalf,
rounddown,
type Gap,
type Num,
type Row,
} from "./B09_Estimation_Calc_Base";
/** 공종 나무 — [번호, 이름, 부모] */
export const SECTIONS: Array<[string, string, string | null]> = [
["1", "토공사", null],
["1.1", "준비공", "1"],
["1.2", "절토", "1"],
["1.3", "구조물터파기 및 되메우기", "1"],
["1.4", "측구파기", "1"],
["1.5", "유용성토", "1"],
["1.6", "사토 및 다짐공", "1"],
["2", "구조물공", null],
["2.1", "돌기슭막이공", "2"],
["2.2", "배수공", "2"],
["2.3", "기타구조물", "2"],
["3", "운반공", null],
["4", "자재대", null],
["G", "관급자재대", null],
];
const PARTS = ["labor", "material", "expense"];
/** 184 덮개로 고칠 칸 · 묶음 깊이 한도 */
const FIELDS = ["name", "spec", "unit", "qty", "ref"];
const DEPTH = 5;
const NEW_AUTO = "새 자동 줄";
function amounts(qty: Num, prices: Row | null): Row {
const unit: Record<string, Num> = {};
for (const p of PARTS) unit[p] = prices === null ? null : num(prices[p]);
const out: Row = {};
for (const p of PARTS) out[`price_${p}`] = unit[p];
const known = PARTS.every((p) => unit[p] !== null);
out.price_sum = known ? unit.labor! + unit.material! + unit.expense! : null;
for (const p of PARTS)
out[p] = qty !== null && unit[p] !== null ? rounddown(qty * unit[p]!, 0) : null;
const filled = PARTS.every((p) => out[p] !== null);
out.sum = filled ? out.labor + out.material + out.expense : null;
return out;
}
/** 항목 줄 — 「연결 없음」은 _gaps 에 담아 둠(보이는 줄만 표 차례로 모음) */
function itemRow(item: Row, prices: Row | null): Row {
const qty = num(item.qty);
const row: Row = {
kind: "item",
key: item.key,
section: item.section ?? null,
name: item.name || "",
spec: item.spec || "",
unit: item.unit || "",
qty,
note: item.note || "",
ref: item.ref ?? null,
...Object.fromEntries(Object.entries(item).filter(([k]) => k.startsWith("auto_"))),
...(item.hand ? { hand: true } : { auto: true }),
...amounts(qty, prices),
};
row._gaps = [];
if (qty === null) row._gaps.push(gap("bill", item.key, row.name, "수량 연결 없음"));
if (prices === null)
row._gaps.push(gap("bill", item.key, row.name, item.why || "단가 연결 없음"));
return row;
}
function materialItems(inputs: Row, compare: Row[]): Row[] {
const qtyOf: Row = {};
for (const m of inputs.materials || []) if ("qty" in m) qtyOf[m.key] = m.qty;
const out: Row[] = [];
for (const row of compare) {
if (row.supply === null || !(row.key in qtyOf)) continue;
out.push({
key: `mat:${row.key}`,
section: row.supply === "관급" ? "G" : "4",
name: row.name,
spec: row.spec,
unit: row.unit,
qty: qtyOf[row.key],
note: `자재 ${row.no}`,
ref: { tab: "material", key: row.key },
_price: row.applied,
});
}
return out;
}
/** 사본 주인 id → 대가 1 단위 단가(145-2 `daegaPrice`) */
export type PriceOf = (owner: string) => Row | null;
const WHY_HAND = "수기 줄 — 연결 없음(우클릭 [연결])";
const WHY_LOGIC = "새 산근 — [저장] 뒤 단가(서버 셈)";
/** 연결 → 1 단위 단가 {no, key, labor, material, expense, sum} — 없으면 null */
export function refPrice(ref: Row | null, books: Row, hopyo: Row, rowKey = ""): Row | null {
let tab = ref?.tab;
let key = ref?.key;
if (tab === "logic") {
tab = "sg";
key = (books.sg_hand || {})[rowKey];
}
if ((tab === "dg" || tab === "sg") && key) {
const listing: Row[] = books[tab === "dg" ? "daega" : "sg_list"] || [];
const row = listing.find((r) => r.key === key);
if (!row) return null;
const no = tab === "dg" ? `대가${row.no}` : `산근 ${row.no}호표`;
const out: Row = { no, tab, key };
for (const p of [...PARTS, "sum"]) out[p] = row[p] ?? null;
return out;
}
return key ? (hopyo[key] ?? null) : null;
}
/** 새 줄(hand:) → [항목, 단가, 까닭] — 연결 = 고른 호표(로직 = 새 산근 키로 풀어 둠) */
export function handItems(user: Row, books: Row, hopyo: Row): Array<[Row, Row | null, string]> {
const out: Array<[Row, Row | null, string]> = [];
for (const hand of (user.bill || {}).rows || []) {
if (!String(hand.key || "").startsWith("hand:")) continue;
const price = refPrice(hand.ref ?? null, books, hopyo, hand.key);
const ref = price?.tab ? { tab: price.tab, key: price.key } : null;
const why = hand.ref?.tab === "logic" ? WHY_LOGIC : WHY_HAND;
out.push([{ ...hand, hand: true, note: price?.no || "", ref }, price, why]);
}
return out;
}
/** 자동 줄 덮개(184) — 고친 칸 = 그 값(null = 빈칸) · auto_{칸} = 자동값 · 연결 덮개 = 그 단가(자재 줄 = 연결 안 받음) */
function covered(
item: Row,
prices: Row | null,
cover: Row | undefined,
books: Row,
hopyo: Row,
): [Row, Row | null] {
if (!cover) return [item, prices];
const out: Row = { ...item };
for (const field of FIELDS) {
if (!(field in cover) || (field === "ref" && item.key.startsWith("mat:"))) continue;
if (field === "qty") out.auto_qty = num(item.qty);
else if (field === "ref") out.auto_ref = item.ref ?? null;
else out[`auto_${field}`] = item[field] || "";
out[field] = cover[field];
}
if ("auto_ref" in out) {
prices = refPrice(cover.ref, books, hopyo, item.key);
const tab = prices?.tab;
out.ref = tab ? { tab, key: prices!.key } : null;
out.note = prices?.no || "";
if (cover.ref?.tab === "logic") out.why = WHY_LOGIC;
}
return [out, prices];
}
/** 기본 공종 제목(자동 묶음) — 이름 덮개만 · 번호는 no(차례 자동) */
function titleRow(code: string, name: string, parent: string | null, cover: Row | undefined): Row {
const row: Row = { kind: "title", key: `sec:${code}`, section: code, name, auto: true };
if (cover && "name" in cover) {
row.auto_name = name;
row.name = cover.name || "";
}
row.parent = parent ? `sec:${parent}` : null;
row._home = row.parent;
return row;
}
/** 새 묶음 줄(182 · 184) — 이름만 · 금액 = 자손 항목 합 */
function groupRow(item: Row): Row {
return {
kind: "group",
key: item.key,
section: item.section ?? null,
name: item.name || "",
spec: item.spec || "",
unit: item.unit || "",
qty: null,
note: "",
ref: null,
hand: true,
};
}
function handRow(item: Row, prices: Row | null, why: string): Row {
return item.kind === "group" ? groupRow(item) : itemRow({ ...item, why }, prices);
}
/** 옛 꼴 손 줄 끼우기 — after 줄 바로 뒤 · 제목(sec:) = 그 공종 첫 자리 · 못 찾으면 끝 */
function place(items: Row[], hand: Row, after: string): void {
let at = items.findIndex((row) => row.key === after);
if (at >= 0) at += 1;
else if (after === `sec:${hand.section}`) at = items.findIndex((r) => r.section === hand.section);
items.splice(at < 0 ? items.length : at, 0, hand);
}
/** 구조물 열 → 대가 블록마다 한 줄(수량 = 그 블록 주인 값 합 ROUND 3 · 대가 없는 주인 = 한 줄) ·
* 줄 키 꼬리 = 대가 블록 키(184 — 대가 번호가 밀려도 덮개가 같은 줄) */
export function splitByBlock(item: Row, priceOf: PriceOf): Array<[Row, Row | null]> {
const groups = new Map<string, Row>();
for (const [owner, value] of Object.entries(item.owners || {})) {
const price = priceOf(owner);
const tag = price ? String(price.key || price.no || "") : "";
if (!groups.has(tag)) groups.set(tag, { qty: 0, price, owners: [] });
const group = groups.get(tag)!;
group.qty += num(value) || 0;
group.owners.push(owner);
}
const out: Array<[Row, Row | null]> = [];
for (const [tag, group] of groups) {
const no = String(group.price?.no || "");
const part: Row = {
...item,
key: tag ? `${item.key}#${tag}` : item.key,
qty: roundHalf(group.qty, 3),
note: [no, item.note || ""].filter((t) => t).join(" · "),
owners: Object.fromEntries(group.owners.map((o: string) => [o, item.owners[o]])),
};
// 대가 블록(145-2) — 이동 · 집계표 역전개
if (group.price?.key) part.ref = { tab: "dg", key: group.price.key };
out.push([part, group.price]);
}
return out;
}
type Hands = Array<[Row, Row | null, string]>;
/** 옛 꼴(166-2 · 182) — 손 줄 = after 뒤 · 부모 = 같은 공종 손 묶음(아니면 공종 제목) */
function orderOld(_bill: Row, titles: Row[], items: Row[], hands: Hands): Row[] {
const groups = new Map<string, Row>();
for (const [item] of hands) if (item.kind === "group") groups.set(item.key, item);
for (const [item, prices, why] of hands) {
const row = handRow(item, prices, why);
const up = groups.get(item.parent ?? "");
row.parent = up && (up.section ?? null) === (item.section ?? null) ? up.key : null;
place(items, row, item.after || "");
}
const out: Row[] = [];
for (const title of titles) {
out.push(title);
for (const row of items) {
if (row.section !== title.section) continue;
row._home = title.key;
row.parent = row.parent || title.key;
out.push(row);
}
}
return out;
}
/** 184 v2 — 저장 차례 · 부모 · gone 뺌 · 저장에 없는 자동 줄 = 기본 부모 끝 「새 자동 줄」 */
function orderV2(bill: Row, titles: Row[], items: Row[], hands: Hands): Row[] {
const auto = new Map<string, Row>();
for (const row of titles) auto.set(row.key, row);
for (const row of items) {
row._home = `sec:${row.section}`;
auto.set(row.key, row);
}
const gone = new Set<string>(
(bill.gone || []).filter((k: string) => k !== "sec:G" && !k.startsWith("mat:")),
);
const fresh = new Map<string, [Row, Row | null, string]>();
for (const hand of hands) fresh.set(hand[0].key, hand);
const out: Row[] = [];
const seen = new Set<string>();
for (const saved of bill.rows || []) {
const key = saved.key;
if (seen.has(key)) continue;
let row: Row;
if (fresh.has(key)) {
row = handRow(...fresh.get(key)!);
row._home = null;
} else if (auto.has(key) && !gone.has(key)) row = auto.get(key)!;
else continue;
seen.add(key);
row.parent = saved.parent ?? null;
out.push(row);
}
for (const row of [...titles, ...items]) {
if (seen.has(row.key) || gone.has(row.key)) continue;
row.parent = row._home;
if (row.kind === "item") row.note = [row.note, NEW_AUTO].filter((t) => t).join(" · ");
out.push(row);
}
return out;
}
/** 부모 바로잡기(없음 · 항목 · 돎 = 기본 부모 / 맨 위 · sec:G 맨 위 · 관급 자재 줄 = sec:G 안) →
* 표 차례(부모 먼저 · 형제 = 저장 차례) · level · cost · section(깊이 5 넘음 = 레벨 4 조상 아래) */
function tree(order: Row[]): Row[] {
const byKey = new Map<string, Row>();
for (const row of order) byKey.set(row.key, row);
const home = (row: Row) => (row._home != null && byKey.has(row._home) ? row._home : null);
const loops = (row: Row): boolean => {
let at = byKey.get(row.parent ?? "");
for (let n = 0; at && n <= order.length; n += 1) {
if (at === row) return true;
at = byKey.get(at.parent ?? "");
}
return false;
};
const root = (row: Row): Row => {
while (row.parent) row = byKey.get(row.parent)!;
return row;
};
for (const row of order) {
const up = byKey.get(row.parent ?? "");
if (row.key === "sec:G") row.parent = null;
else if (!up || up.kind === "item" || up === row) row.parent = home(row);
}
for (const row of order) {
if (!loops(row)) continue;
row.parent = home(row);
if (loops(row)) row.parent = null;
}
for (const row of order)
if (row.key.startsWith("mat:") && row._home === "sec:G" && root(row).key !== "sec:G")
row.parent = "sec:G";
const kids = new Map<string | null, Row[]>();
for (const row of order) {
if (!kids.has(row.parent)) kids.set(row.parent, []);
kids.get(row.parent)!.push(row);
}
const rows: Row[] = [];
const stack: Array<[Row, Row[]]> = [...(kids.get(null) || [])].reverse().map((r) => [r, []]);
while (stack.length) {
let [row, chain] = stack.pop()!;
if (chain.length >= DEPTH) {
chain = chain.slice(0, DEPTH - 1);
row.parent = chain[chain.length - 1].key;
}
row.level = chain.length + 1;
row.cost = (chain.length ? chain[0] : row).key === "sec:G" ? "supply" : "direct";
if (row.kind !== "title") row.section = chain.length ? chain[chain.length - 1].section : null;
rows.push(row);
for (const kid of [...(kids.get(row.key) || [])].reverse()) stack.push([kid, [...chain, row]]);
}
return rows;
}
export function computeBill(
inputs: Row,
user: Row,
base: Row,
priceOf: PriceOf | null = null,
books: Row | null = null,
): Row {
const hopyo: Row = inputs.hopyo || {};
const book: Row = books || {};
const bill: Row = user.bill || {};
const v2 = bill.v === 2;
const cover: Record<string, Row> = {};
if (v2) {
for (const r of bill.rows || []) if (!r.key.startsWith("hand:")) cover[r.key] = r;
} else for (const [k, v] of Object.entries(bill.qty || {})) cover[k] = { qty: v };
const items: Row[] = [];
for (const item of (inputs.bill || {}).items || []) {
const parts: Array<[Row, Row | null]> =
priceOf !== null && item.owners && Object.keys(item.owners).length
? splitByBlock(item, priceOf)
: [[item, refPrice(item.ref ?? null, book, hopyo)]];
for (const [part, prices] of parts)
items.push(itemRow(...covered(part, prices, cover[part.key], book, hopyo)));
}
for (const item of materialItems(inputs, base.compare)) {
const price: Num = item._price;
const prices = price === null ? null : { labor: 0, material: price, expense: 0 };
items.push(itemRow(...covered(item, prices, cover[item.key], book, hopyo)));
}
const titles = SECTIONS.map(([c, n, p]) => titleRow(c, n, p, cover[`sec:${c}`]));
const hands = handItems(user, book, hopyo);
const rows = tree((v2 ? orderV2 : orderOld)(bill, titles, items, hands));
const owner = rows.filter((row) => row.kind === "item" && row.cost === "supply");
let net = 0;
for (const row of owner) net += row.material || 0;
const pct = num((user.estimate || {}).procurement_fee_pct);
const fee = pct !== null ? rounddown((net * pct) / 100, 0) : null;
const feeRow: Row = {
kind: "item",
key: "fee",
section: "G",
name: "조달수수료",
spec: "관급 재료비 × 율",
unit: "%",
qty: pct,
note: "관급 순자재대 밑수",
ref: null,
auto: true,
price_labor: 0,
price_material: net,
price_expense: 0,
price_sum: net,
labor: pct !== null ? 0 : null,
material: fee,
expense: pct !== null ? 0 : null,
sum: fee,
parent: "sec:G",
level: 2,
cost: "supply",
_gaps: pct === null ? [gap("bill", "fee", "조달수수료", "조달수수료율 입력 없음")] : [],
};
if (owner.length) {
// 조달수수료 = sec:G 끝 고정
let at = rows.findIndex((row) => row.key === "sec:G") + 1;
while (at < rows.length && rows[at].level > 1) at += 1;
rows.splice(at, 0, feeRow);
}
const byKey = new Map<string, Row>();
for (const row of rows) byKey.set(row.key, row);
for (const row of rows)
if (row.kind !== "item")
Object.assign(row, { sum: 0, labor: 0, material: 0, expense: 0, blank: 0 });
const direct: Row = { sum: 0, labor: 0, material: 0, expense: 0 };
let blank = 0;
for (const row of rows) {
if (row.kind !== "item") continue;
for (let at = byKey.get(row.parent ?? ""); at; at = byKey.get(at.parent ?? "")) {
for (const p of ["sum", ...PARTS]) at[p] += row[p] || 0;
if (row.sum === null) at.blank += 1;
}
if (row.cost === "direct") {
for (const p of Object.keys(direct)) direct[p] += row[p] || 0;
if (row.sum === null) blank += 1;
}
}
const gaps: Gap[] = [];
const seq = new Map<string | null, number>();
for (const row of rows) {
gaps.push(...(row._gaps || []));
delete row._gaps;
delete row._home;
row.no = "";
if (row.kind === "title")
row.note =
(row.key === "sec:G" ? "(제외금액)" : "") + (row.blank ? ` 빈 줄 ${row.blank}` : "");
else if (row.kind === "group") row.note = row.blank ? `빈 줄 ${row.blank}` : "";
if (row.kind !== "item" && row.cost === "direct" && row.level <= 2) {
// 번호 = 레벨 1 · 2 묶음 차례(1 · 1.1 · 관급 없음)
const up = row.parent;
seq.set(up, (seq.get(up) || 0) + 1);
row.no = up ? `${byKey.get(up)!.no}.${seq.get(up)}` : String(seq.get(up));
}
}
const totals = {
direct: { ...direct, blank },
owner: { net_material: net, fee, total: net + (fee || 0) },
};
// 원가계산서로 넘김(145-4 이음 꼴) — 빈 줄이 있으면 모르는 값 = null
const known = blank === 0;
const ownerKnown = owner.every((row) => row.sum !== null);
const sumsOut = {
material: known ? direct.material : null,
labor: known ? direct.labor : null,
expense: known ? direct.expense : null,
supply_net: ownerKnown ? net : null,
};
return { rows, totals, sums: sumsOut, gaps };
}
/** 144 바탕 + 145-3 설계내역서 */
export function computeEstimation(
inputs: Row,
user: Row,
priceOf: PriceOf | null = null,
books: Row | null = null,
): Row {
const base = computeBase(inputs, user);
const bill = computeBill(inputs, user, base, priceOf, books);
return { ...base, bill, gaps: [...base.gaps, ...bill.gaps] };
}