Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
490 lines
19 KiB
TypeScript
490 lines
19 KiB
TypeScript
/* =============================================================================
|
||
* 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] };
|
||
}
|