Files
Aislo/B09_Estimation/B09_Estimation_Calc_Bill.py
T

521 lines
24 KiB
Python
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 설계내역서 셈(PLAN 145-3) — 화면 없음 · 저장 안 함 · 파일 · M01 안 읽음.
⚠ 짝 = `B09_Estimation_Calc_Bill.ts`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 ·
거울 시험 `resources/tester/test_145_3_b09_bill.py` · `test_182_b09_bill_level.py`(레벨) ·
`test_184_2_b09_bill_free.py`(자유 편집).
실무 엑셀 설계내역서 꼴 · 기본 공종 나무(초기값) = 엑셀 꼴(`SECTIONS` · 공구 안 나눔):
1 토공사(1.1~1.6) · 2 구조물공(2.1~2.3) · 3 운반공 · 4 자재대 · 관급자재대(제외금액)
항목(토공 · 구조물 · 운반) = 서버가 B08 수량으로 짠 줄(`B09_Estimation_Bill_Items`)
4 자재대 = 자재단가대비표에서 「별도」 표시한 B08 자재 · 관급자재대 = 「관급」 표시 + 조달수수료
단가 = 호표(대가 · 산근 — 145-1 · 2 가 `inputs.hopyo` 로 줌) · 자재 = 대비표 적용 단가
금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 합계 = 세 비목 합 ·
조달수수료 = ROUNDDOWN(관급 순자재대 × 율 ÷ 100, 0)(율 = 사용자 입력 · 빈칸 시작)
제목 줄 = 아래 항목 금액 합(엑셀 SUMIF — 빈 줄은 0 이 아니라 「빈 줄 n」 으로 셈)
빈칸 = 수량이나 단가를 낸 원천이 없으면 그 줄 금액 None + 「연결 없음」 목록.
단가 = `ref_price(ref)` 하나 — dg = 일위대가목록표 · sg = 단가산출근거목록표 ·
logic = M01 로직 새 산근(서버가 `sg_hand{줄 키: 산근 키}` 로 줌 · 그전엔 빈칸 「[저장] 뒤」) ·
그 밖 = `inputs.hopyo` · 비고 = 「대가n」 · 「산근 n호표」(155-3 꼴).
자유 편집(184) = 기본 로직 표 = 초기값(저장 안 함) ·
사용자 값 `user.bill` v2 = 덮개(계약 — 184-3 화면):
{"v": 2,
"rows": [표 차례 전체(order · 부모 먼저 · 자손 이어서) {
"key": 줄 키, "parent": 묶음 줄 키(`sec:*` · `hand:*`) | 없음 = 맨 위,
자동 줄(`sec:*` · `earth:` · `st:` · `haul:` · `mat:`) = 고친 칸만 —
칸 없음 = 자동(B08 따라감) · 값 = 지킴 · null = 일부러 빈칸 ·
칸 = name · spec · unit · qty · ref
(제목 `sec:*` = name 만 · `mat:` = ref 없음 ·
`fee` = 덮개 없음 · 차례에 안 넣음 — `sec:G` 끝 고정)
새 줄(`hand:*`) = 모든 칸 + "kind": "group"(묶음 · 수량 · 연결 없음) | 없음 = 항목 ·
"cost": "direct"(맨 위 새 묶음 · 없음도 direct)}],
"gone": [지운 자동 줄 키 — `sec:G` · `fee` · `mat:` 는 안 받음(자재 줄 주인 = 대비표 표시)]}
덮개 없음 = 기본 표 그대로 · [초기화] = `bill` 지움 ·
`rows` 에 없고 `gone` 에도 없는 자동 줄(B08 새 열 · 대비표 새 표시) =
기본 부모 끝 + 비고 「새 자동 줄」(기본 부모가 없으면 표 끝 맨 위) ·
원천이 안 낸 키 = 안 보임 ·
부모 못 찾음 · 묶음 아님 · 돎 = 자동 줄 기본 부모 / 새 줄 맨 위 · `sec:G` = 맨 위 고정 ·
관급 자재 줄 = `sec:G` 밖 못 감(밖이면 `sec:G` 아래) · 깊이 5 넘음 = 레벨 4 조상 아래.
옛 꼴(v 없음 · 166-2 · 182) = `qty{줄 키: 수}` 수량 덮개 + `rows[]` 손 줄(`section` ·
`after` 앞 줄 뒤에 끼움 · `parent` = 같은 공종 손 묶음) — 그대로 읽음(화면 첫 손질 때 v2).
셈 출력 줄 칸(계약) = kind("title" 자동 묶음 · "group" 새 묶음 · "item") · key ·
name(번호 뺀 이름) · parent(실제 부모 키 · None = 맨 위) · level(1~5 = 깊이) ·
no(레벨 1 · 2 묶음 차례 번호 「1」 · 「1.1」 · 관급 · 항목 = "") ·
cost("direct" | "supply" = 맨 위 조상 갈래 · `sec:G` 만 supply) ·
auto(True = 자동 줄) | hand(True = 새 줄) · auto_{name|spec|unit|qty|ref}(고친 칸만 = 자동값) ·
section(가장 가까운 제목 공종 · 없으면 None).
묶음 금액 = 자손 항목 합 + 「빈 줄 n」 ·
구조물 블록 줄 키 꼬리 = 대가 블록 키(대가 번호 밀림에 안 흔들림).
원가계산서로 넘길 합 = `sums`(cost direct 항목의 직접재료비 · 직접노무비 · 산출경비 ·
supply 항목의 관급 순자재대 — 145-4 이음 꼴 · 빈 줄이 있으면 None) ·
화면 표시용 = `totals`(아는 금액 합 · 빈 줄 수 · 조달수수료).
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from B09_Estimation.B09_Estimation_Calc_Base import compute_base, gap, num, round_half, rounddown
#: 공종 나무 — (번호, 이름, 부모) · 엑셀 기번3 공통 1공구 꼴(공구 안 나눔)
SECTIONS: list[tuple[str, str, str | None]] = [
("1", "토공사", None),
("1.1", "준비공", "1"),
("1.2", "절토", "1"),
("1.3", "구조물터파기 및 되메우기", "1"),
("1.4", "측구파기", "1"),
("1.5", "유용성토", "1"),
("1.6", "사토 및 다짐공", "1"),
("2", "구조물공", None),
("2.1", "돌기슭막이공", "2"),
("2.2", "배수공", "2"),
("2.3", "기타구조물", "2"),
("3", "운반공", None),
("4", "자재대", None),
("G", "관급자재대", None),
]
PARTS = ("labor", "material", "expense")
#: 184 덮개로 고칠 칸 · 묶음 깊이 한도
FIELDS = ("name", "spec", "unit", "qty", "ref")
DEPTH = 5
NEW_AUTO = "새 자동 줄"
def _amounts(qty: float | None, prices: dict[str, float | None] | None) -> dict[str, Any]:
if prices is None:
unit = {p: None for p in PARTS}
else:
unit = {p: num(prices.get(p)) for p in PARTS}
out: dict[str, Any] = {f"price_{p}": unit[p] for p in PARTS}
known = all(unit[p] is not None for p in PARTS)
out["price_sum"] = unit["labor"] + unit["material"] + unit["expense"] if known else None
for p in PARTS:
out[p] = rounddown(qty * unit[p], 0) if qty is not None and unit[p] is not None else None
filled = all(out[p] is not None for p in PARTS)
out["sum"] = out["labor"] + out["material"] + out["expense"] if filled else None
return out
def _item_row(item: dict[str, Any], prices: dict[str, Any] | None) -> dict[str, Any]:
"""항목 줄 — 「연결 없음」은 `_gaps` 에 담아 둠(보이는 줄만 표 차례로 모음)."""
qty = num(item.get("qty"))
row = {
"kind": "item",
"key": item["key"],
"section": item.get("section"),
"name": item.get("name") or "",
"spec": item.get("spec") or "",
"unit": item.get("unit") or "",
"qty": qty,
"note": item.get("note") or "",
"ref": item.get("ref"),
**{k: v for k, v in item.items() if k.startswith("auto_")},
**({"hand": True} if item.get("hand") else {"auto": True}),
**_amounts(qty, prices),
}
row["_gaps"] = []
if qty is None:
row["_gaps"].append(gap("bill", item["key"], row["name"], "수량 연결 없음"))
if prices is None:
why = item.get("why") or "단가 연결 없음"
row["_gaps"].append(gap("bill", item["key"], row["name"], why))
return row
def _material_items(inputs: dict[str, Any], compare: list[dict[str, Any]]) -> list[dict]:
"""대비표에서 「별도」 · 「관급」 표시한 수량 있는 자재 → 4 자재대 · 관급자재대 항목."""
qty_of = {m["key"]: m.get("qty") for m in inputs.get("materials") or [] if "qty" in m}
out = []
for row in compare:
if row["supply"] is None or row["key"] not in qty_of:
continue
out.append(
{
"key": f"mat:{row['key']}",
"section": "G" if row["supply"] == "관급" else "4",
"name": row["name"],
"spec": row["spec"],
"unit": row["unit"],
"qty": qty_of[row["key"]],
"note": f"자재 {row['no']}",
"ref": {"tab": "material", "key": row["key"]},
"_price": row["applied"],
}
)
return out
PriceOf = Callable[[str], dict[str, Any] | None]
WHY_HAND = "수기 줄 — 연결 없음(우클릭 [연결])"
WHY_LOGIC = "새 산근 — [저장] 뒤 단가(서버 셈)"
def ref_price(
ref: dict[str, Any] | None, books: dict[str, Any], hopyo: dict[str, Any], row_key: str = ""
) -> dict[str, Any] | None:
"""연결 → 1 단위 단가 `{no, key, labor, material, expense, sum}` — 없으면 None."""
ref = ref or {}
tab, key = ref.get("tab"), ref.get("key")
if tab == "logic":
tab, key = "sg", (books.get("sg_hand") or {}).get(row_key)
if tab in ("dg", "sg") and key:
listing = books.get("daega" if tab == "dg" else "sg_list") or []
row = next((r for r in listing if r["key"] == key), None)
if row is None:
return None
no = f"대가{row['no']}" if tab == "dg" else f"산근 {row['no']}호표"
return {"no": no, "tab": tab, "key": key, **{p: row.get(p) for p in (*PARTS, "sum")}}
return hopyo.get(key) if key else None
def hand_items(user: dict[str, Any], books: dict[str, Any], hopyo: dict) -> list[tuple]:
"""새 줄(`hand:`) → (항목, 단가) — 연결 = 고른 호표(로직 = 새 산근 키로 풀어 둠)."""
out = []
for hand in (user.get("bill") or {}).get("rows") or []:
if not str(hand.get("key") or "").startswith("hand:"):
continue
price = ref_price(hand.get("ref"), books, hopyo, hand["key"])
ref = {"tab": price["tab"], "key": price["key"]} if price and price.get("tab") else None
why = WHY_LOGIC if (hand.get("ref") or {}).get("tab") == "logic" else WHY_HAND
item = {**hand, "hand": True, "note": (price or {}).get("no") or "", "ref": ref}
out.append((item, price, why))
return out
def _covered(
item: dict[str, Any], prices: Any, cover: dict | None, books: dict, hopyo: dict
) -> tuple[dict[str, Any], Any]:
"""자동 줄 덮개(184) — 고친 칸 = 그 값(null = 빈칸) · `auto_{칸}` = 자동값 ·
연결 덮개 = 그 단가(자재 줄 = 단가 주인 대비표라 연결 안 받음)."""
if not cover:
return item, prices
out = dict(item)
for field in FIELDS:
if field not in cover or (field == "ref" and item["key"].startswith("mat:")):
continue
if field == "qty":
out["auto_qty"] = num(item.get("qty"))
elif field == "ref":
out["auto_ref"] = item.get("ref")
else:
out[f"auto_{field}"] = item.get(field) or ""
out[field] = cover[field]
if "auto_ref" in out:
prices = ref_price(cover["ref"], books, hopyo, item["key"])
tab = (prices or {}).get("tab")
out["ref"] = {"tab": tab, "key": prices["key"]} if tab else None
out["note"] = (prices or {}).get("no") or ""
if (cover["ref"] or {}).get("tab") == "logic":
out["why"] = WHY_LOGIC
return out, prices
def _title_row(code: str, name: str, parent: str | None, cover: dict | None) -> dict[str, Any]:
"""기본 공종 제목(자동 묶음) — 이름 덮개만 · 번호는 `no`(차례 자동)."""
row = {"kind": "title", "key": f"sec:{code}", "section": code, "name": name, "auto": True}
if cover and "name" in cover:
row["auto_name"] = name
row["name"] = cover["name"] or ""
row["parent"] = f"sec:{parent}" if parent else None
row["_home"] = row["parent"]
return row
def _group_row(item: dict[str, Any]) -> dict[str, Any]:
"""새 묶음 줄(182 · 184) — 이름만 · 금액 = 자손 항목 합."""
return {
"kind": "group",
"key": item["key"],
"section": item.get("section"),
"name": item.get("name") or "",
"spec": item.get("spec") or "",
"unit": item.get("unit") or "",
"qty": None,
"note": "",
"ref": None,
"hand": True,
}
def _hand_row(item: dict[str, Any], prices: Any, why: str) -> dict[str, Any]:
if item.get("kind") == "group":
return _group_row(item)
return _item_row({**item, "why": why}, prices)
def _place(items: list[dict[str, Any]], hand: dict[str, Any], after: str) -> None:
"""옛 꼴 손 줄 끼우기 — `after` 줄 바로 뒤 · 제목(`sec:`) = 그 공종 첫 자리 · 못 찾으면 끝."""
at = next((n + 1 for n, row in enumerate(items) if row["key"] == after), None)
if at is None and after == f"sec:{hand['section']}":
at = next((n for n, row in enumerate(items) if row["section"] == hand["section"]), None)
items.insert(len(items) if at is None else at, hand)
def split_by_block(item: dict[str, Any], price_of: PriceOf) -> list[tuple[dict, dict | None]]:
"""구조물 열 → 대가 블록마다 한 줄 — 수량 = 그 블록 사본 주인 값 합(ROUND 3) ·
대가 없는 주인은 한 줄로 모음(단가 빈칸 「연결 없음」) · 차례 = 주인이 처음 나온 차례 ·
줄 키 꼬리 = 대가 블록 키(184 — 대가 번호가 밀려도 덮개가 같은 줄)."""
groups: dict[str, dict[str, Any]] = {}
for owner, value in (item.get("owners") or {}).items():
price = price_of(owner)
tag = str(price.get("key") or price.get("no") or "") if price else ""
group = groups.setdefault(tag, {"qty": 0.0, "price": price, "owners": []})
group["qty"] += num(value) or 0
group["owners"].append(owner)
out = []
for tag, group in groups.items():
no = str((group["price"] or {}).get("no") or "")
part = {
**item,
"key": f"{item['key']}#{tag}" if tag else item["key"],
"qty": round_half(group["qty"], 3),
"note": " · ".join(t for t in (no, item.get("note") or "") if t),
"owners": {o: item["owners"][o] for o in group["owners"]},
}
if group["price"] and group["price"].get("key"): # 대가 블록(145-2) — 이동 · 집계표 역전개
part["ref"] = {"tab": "dg", "key": group["price"]["key"]}
out.append((part, group["price"]))
return out
def _order_old(bill: dict, titles: list[dict], items: list[dict], hands: list[tuple]) -> list:
"""옛 꼴(166-2 · 182) — 손 줄 = `after` 뒤 · 부모 = 같은 공종 손 묶음(아니면 공종 제목)."""
groups = {i["key"]: i for i, _p, _w in hands if i.get("kind") == "group"}
for item, prices, why in hands:
row = _hand_row(item, prices, why)
up = groups.get(item.get("parent") or "")
row["parent"] = up["key"] if up and up.get("section") == item.get("section") else None
_place(items, row, item.get("after") or "")
out = []
for title in titles:
out.append(title)
for row in items:
if row["section"] == title["section"]:
row["_home"] = title["key"]
row["parent"] = row.get("parent") or title["key"]
out.append(row)
return out
def _order_v2(bill: dict, titles: list[dict], items: list[dict], hands: list[tuple]) -> list:
"""184 v2 — 저장 차례 · 부모 · `gone` 뺌 · 저장에 없는 자동 줄 = 기본 부모 끝 「새 자동 줄」."""
auto = {row["key"]: row for row in titles}
for row in items:
row["_home"] = f"sec:{row['section']}"
auto[row["key"]] = row
gone = {k for k in bill.get("gone") or [] if k != "sec:G" and not k.startswith("mat:")}
new = {item["key"]: (item, prices, why) for item, prices, why in hands}
out, seen = [], set()
for saved in bill.get("rows") or []:
key = saved["key"]
if key in seen:
continue
if key in new:
row = _hand_row(*new[key])
row["_home"] = None
elif key in auto and key not in gone:
row = auto[key]
else:
continue
seen.add(key)
row["parent"] = saved.get("parent")
out.append(row)
for row in [*titles, *items]:
if row["key"] in seen or row["key"] in gone:
continue
row["parent"] = row["_home"]
if row["kind"] == "item":
row["note"] = " · ".join(t for t in (row["note"], NEW_AUTO) if t)
out.append(row)
return out
def _tree(order: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""부모 바로잡기(없음 · 항목 · 돎 = 기본 부모 / 맨 위 · `sec:G` 맨 위 ·
관급 자재 줄 = `sec:G` 안) → 표 차례(부모 먼저 · 형제 = 저장 차례) · level · cost · section
(깊이 5 넘음 = 레벨 4 조상 아래)."""
by_key = {row["key"]: row for row in order}
def loops(row: dict[str, Any]) -> bool:
at, n = by_key.get(row["parent"] or ""), 0
while at is not None and n <= len(order):
if at is row:
return True
at, n = by_key.get(at["parent"] or ""), n + 1
return False
def root(row: dict[str, Any]) -> dict[str, Any]:
while row["parent"]:
row = by_key[row["parent"]]
return row
for row in order:
up = by_key.get(row.get("parent") or "")
if row["key"] == "sec:G":
row["parent"] = None
elif up is None or up["kind"] == "item" or up is row:
row["parent"] = row["_home"] if row["_home"] in by_key else None
for row in order:
if loops(row):
row["parent"] = row["_home"] if row["_home"] in by_key else None
if loops(row):
row["parent"] = None
for row in order:
if (
row["key"].startswith("mat:")
and row["_home"] == "sec:G"
and root(row)["key"] != "sec:G"
):
row["parent"] = "sec:G"
kids: dict[Any, list[dict[str, Any]]] = {}
for row in order:
kids.setdefault(row["parent"], []).append(row)
rows: list[dict[str, Any]] = []
stack: list[tuple[dict, list]] = [(row, []) for row in reversed(kids.get(None, []))]
while stack:
row, chain = stack.pop()
if len(chain) >= DEPTH:
chain = chain[: DEPTH - 1]
row["parent"] = chain[-1]["key"]
row["level"] = len(chain) + 1
row["cost"] = "supply" if (chain[0] if chain else row)["key"] == "sec:G" else "direct"
if row["kind"] != "title":
row["section"] = chain[-1]["section"] if chain else None
rows.append(row)
stack.extend((kid, [*chain, row]) for kid in reversed(kids.get(row["key"], [])))
return rows
def compute_bill(
inputs: dict[str, Any],
user: dict[str, Any],
base: dict[str, Any],
price_of: PriceOf | None = None,
books: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""`price_of(사본 주인 id)` = 대가 1 단위 단가(145-2 `daega_price`) —
주면 구조물 열을 블록마다 나눔 ·
`books` = 호표 목록(`daega` · `sg_list` · `sg_hand` · 166-2)."""
hopyo = inputs.get("hopyo") or {}
books = books or {}
bill = user.get("bill") or {}
v2 = bill.get("v") == 2
if v2:
cover = {r["key"]: r for r in bill.get("rows") or [] if not r["key"].startswith("hand:")}
else:
cover = {k: {"qty": v} for k, v in (bill.get("qty") or {}).items()}
items: list[dict[str, Any]] = []
for item in (inputs.get("bill") or {}).get("items") or []:
if price_of is not None and item.get("owners"):
parts = split_by_block(item, price_of)
else:
parts = [(item, ref_price(item.get("ref"), books, hopyo))]
for part, prices in parts:
items.append(_item_row(*_covered(part, prices, cover.get(part["key"]), books, hopyo)))
for item in _material_items(inputs, base["compare"]):
price = item["_price"]
prices = None if price is None else {"labor": 0, "material": price, "expense": 0}
items.append(_item_row(*_covered(item, prices, cover.get(item["key"]), books, hopyo)))
titles = [_title_row(c, n, p, cover.get(f"sec:{c}")) for c, n, p in SECTIONS]
hands = hand_items(user, books, hopyo)
rows = _tree((_order_v2 if v2 else _order_old)(bill, titles, items, hands))
owner = [row for row in rows if row["kind"] == "item" and row["cost"] == "supply"]
net = 0.0
for row in owner:
net += row["material"] or 0
pct = num(((user.get("estimate") or {}).get("procurement_fee_pct")))
fee = rounddown(net * pct / 100, 0) if pct is not None else None
fee_row = {
"kind": "item",
"key": "fee",
"section": "G",
"name": "조달수수료",
"spec": "관급 재료비 × 율",
"unit": "%",
"qty": pct,
"note": "관급 순자재대 밑수",
"ref": None,
"auto": True,
"price_labor": 0,
"price_material": net,
"price_expense": 0,
"price_sum": net,
"labor": 0 if pct is not None else None,
"material": fee,
"expense": 0 if pct is not None else None,
"sum": fee,
"parent": "sec:G",
"level": 2,
"cost": "supply",
"_gaps": [gap("bill", "fee", "조달수수료", "조달수수료율 입력 없음")]
if pct is None
else [],
}
if owner: # 조달수수료 = `sec:G` 끝 고정
at = next(n for n, row in enumerate(rows) if row["key"] == "sec:G") + 1
while at < len(rows) and rows[at]["level"] > 1:
at += 1
rows.insert(at, fee_row)
by_key = {row["key"]: row for row in rows}
for row in rows:
if row["kind"] != "item":
row.update({"sum": 0.0, "labor": 0.0, "material": 0.0, "expense": 0.0, "blank": 0})
direct = {p: 0.0 for p in ("sum", *PARTS)}
blank = 0
for row in rows:
if row["kind"] != "item":
continue
at = by_key.get(row["parent"] or "")
while at is not None:
for p in ("sum", *PARTS):
at[p] += row[p] or 0
if row["sum"] is None:
at["blank"] += 1
at = by_key.get(at["parent"] or "")
if row["cost"] == "direct":
for p in direct:
direct[p] += row[p] or 0
if row["sum"] is None:
blank += 1
gaps: list[dict[str, str]] = []
seq: dict[Any, int] = {}
for row in rows:
gaps.extend(row.pop("_gaps", []))
row.pop("_home", None)
row["no"] = ""
if row["kind"] == "title":
empty = f" 빈 줄 {row['blank']}" if row["blank"] else ""
row["note"] = ("(제외금액)" if row["key"] == "sec:G" else "") + empty
elif row["kind"] == "group":
row["note"] = f"빈 줄 {row['blank']}" if row["blank"] else ""
if row["kind"] != "item" and row["cost"] == "direct" and row["level"] <= 2:
up = row["parent"] # 번호 = 레벨 1 · 2 묶음 차례(1 · 1.1 · 관급 없음)
seq[up] = seq.get(up, 0) + 1
row["no"] = f"{by_key[up]['no']}.{seq[up]}" if up else str(seq[up])
totals = {
"direct": {**direct, "blank": blank},
"owner": {"net_material": net, "fee": fee, "total": net + (fee or 0)},
}
# 원가계산서로 넘김(145-4 이음 꼴) — 빈 줄이 있으면 모르는 값 = None
known = blank == 0
owner_known = all(row["sum"] is not None for row in owner)
sums_out = {
"material": direct["material"] if known else None,
"labor": direct["labor"] if known else None,
"expense": direct["expense"] if known else None,
"supply_net": net if owner_known else None,
}
return {"rows": rows, "totals": totals, "sums": sums_out, "gaps": gaps}
def compute_estimation(
inputs: dict[str, Any],
user: dict[str, Any],
price_of: PriceOf | None = None,
books: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""144 바탕 + 145-3 설계내역서 — 「연결 없음」 목록은 둘을 이음."""
base = compute_base(inputs, user)
bill = compute_bill(inputs, user, base, price_of, books)
return {**base, "bill": bill, "gaps": base["gaps"] + bill["gaps"]}