feat(b09): 재료비목록표 = 쓰는 자재만 · 수량 = 역전개 총수량 · 비고 = 쓰는 곳 (183-1)

- 수요: 사본 일위대가 자재 · 굳힌 사전조합 자재 코드도(Linked.used_keys) → 대가 재료 줄 단가가 MT 줄에서 남
- 두 번 셈(Router.compute_twice): 1차 셈 → 대가 · 산근 · 중기 재료 줄 · B08 줄 · 관급 · 별도 줄만 남김(Summary.used_materials) · 1차 역전개 총수량 = material_qty → 2차 셈
- 수량 원천: Calc_Base 짝 material_sheet 가 material_qty(없는 키 빈칸) · 없으면 입력 줄 수량(거울)
- 화면: 비고 = 쓰는 곳(대가 · 산근 · 중기 n호표 · 내역 자재대) · 안내 글
- 실측 엄상준_검증: 141 → 24줄 · 목록 수량 = 재료비수량금액집계표 수량 · 목록표에 없음 0
- 시험: test_183_b09_material_list

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
This commit is contained in:
2026-10-10 11:57:14 +09:00
co-authored by Claude Opus 5.5
parent bc0160da2d
commit 20f17827d3
7 changed files with 179 additions and 20 deletions
+15 -8
View File
@@ -13,8 +13,11 @@
· 견적 = 사용자 손입력(quote · quote_page · 견적서 파일은 `B09_Estimation_Router_Quotes`)
· 관급 · 별도계상 = 사용자 표시(없으면 입력 줄 기본 · B08 파형강관 = 관급)
→ 재료비목록표 단가 = 적용 단가
재료비목록표 = 수량(B08 총괄자재수량집계표 합계 열) × 적용 단가 = 총액 ROUND(·, 0)
(엑셀 재료비수량금액집계표 G 열 자리 · 수량 · 단가 하나라도 빈칸이면 빈칸)
재료비목록표 = 수량 × 적용 단가 = 총액 ROUND(·, 0)
(엑셀 재료비수량금액집계표 G 열 자리 · 수량 · 단가 하나라도 빈칸이면 빈칸) · 수량 =
입력 `material_qty`(183-1 · 서버 1차 셈 역전개 총수량 = 재료비수량금액집계표 수량 ·
없는 키 빈칸) ·
`material_qty` 가 없으면 입력 줄 수량(B08 총괄 합계 열)
중기사용료 = 손료(경비 · 기준가 천원 × 손료계수 ÷ 10,000) + 운전사(노무 · 인 × 시간 노임)
+ 연료(재료 · ℓ/hr × 적용 단가) + 잡품(재료 · 연료 금액 × % ÷ 100)
줄 = ROUNDDOWN(단가 × 수량, 1) · 비목 합 = ROUNDDOWN(Σ, 0) → 중기목록표
@@ -178,14 +181,18 @@ def compare_sheet(
return rows
def material_sheet(compare: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""재료비목록표 — 단가 = 대비표 적용 단가 · 총액 = ROUND(수량 × 단가, 0)."""
keep = ("no", "key", "name", "spec", "unit", "supply", "origin", "qty")
def material_sheet(
compare: list[dict[str, Any]], qty_of: dict[str, Any] | None = None
) -> list[dict[str, Any]]:
"""재료비목록표 — 단가 = 대비표 적용 단가 · 수량 = `qty_of`(183-1) ?? 줄 수량 ·
총액 = ROUND(수량 × 단가, 0)."""
keep = ("no", "key", "name", "spec", "unit", "supply", "origin")
rows = []
for row in compare:
price, q = row["applied"], row["qty"]
price = row["applied"]
q = row["qty"] if qty_of is None else num(qty_of.get(row["key"]))
amount = None if price is None or q is None else round_half(q * price, 0)
rows.append({**{k: row[k] for k in keep}, "price": price, "amount": amount})
rows.append({**{k: row[k] for k in keep}, "qty": q, "price": price, "amount": amount})
return rows
@@ -328,7 +335,7 @@ def compute_base(inputs: dict[str, Any], user: dict[str, Any]) -> dict[str, Any]
"expense": expense,
"basis": basis,
"compare": compare,
"material": material_sheet(compare),
"material": material_sheet(compare, inputs.get("material_qty")),
"machine_blocks": blocks,
"machine": machine,
"gaps": gaps,
+7 -5
View File
@@ -9,7 +9,8 @@
* 노무비 · 경비목록표 = 굳힌 단가 · 환율및기초자료 = 운전사 시간 노임 ROUNDDOWN(일당 × 분자 ÷ 분모, 0)
* 자재단가대비표 = 출처 중 최저 자동(견적 포함) · 적용 = 고른 출처 › 사용자 단가 › 최저 ·
* 관급 · 별도 표시 → 재료비목록표
* 재료비목록표 = 수량(B08 총괄 합계 열) × 적용 단가 = 총액 ROUND(·, 0) · 하나라도 빈칸이면 빈칸
* 재료비목록표 = 수량 × 적용 단가 = 총액 ROUND(·, 0) · 하나라도 빈칸이면 빈칸 · 수량 = 입력
* material_qty(183-1 · 서버 역전개 총수량 · 없는 키 빈칸) ?? 입력 줄 수량(B08 총괄 합계 열)
* 중기사용료 = 손료 + 운전사 + 연료 + 잡품 · 줄 ROUNDDOWN 1 · 비목 합 ROUNDDOWN 0 → 중기목록표
* 빈칸 = 원천이 없으면 null · 그 칸을 쓰는 합도 null · 「연결 없음」 목록(gaps)에 남김
* ========================================================================== */
@@ -157,13 +158,14 @@ function compareSheet(materials: Row[], user: Row, gaps: Gap[]): Row[] {
});
}
function materialSheet(compare: Row[]): Row[] {
const keep = ["no", "key", "name", "spec", "unit", "supply", "origin", "qty"];
function materialSheet(compare: Row[], qtyOf?: Row | null): Row[] {
const keep = ["no", "key", "name", "spec", "unit", "supply", "origin"];
return compare.map((row) => {
const price: Num = row.applied;
const q: Num = row.qty;
const q: Num = qtyOf == null ? row.qty : num(qtyOf[row.key]);
return {
...Object.fromEntries(keep.map((k) => [k, row[k]])),
qty: q,
price,
amount: price === null || q === null ? null : roundHalf(q * price, 0),
};
@@ -301,7 +303,7 @@ export function computeBase(inputs: Row, user: Row): Row {
expense,
basis,
compare,
material: materialSheet(compare),
material: materialSheet(compare, inputs.material_qty),
machine_blocks: blocks,
machine,
gaps,
+7 -1
View File
@@ -3,7 +3,9 @@
연결된 사본 = `M02_Item_Snapshot.linked_snapshots`(지정 ∩ 같은 항목 사본 · B08 · B09 공용).
사본마다 `일위대가.json` 담은 로직을 그 사본 단가 묶음(`단가.json`) · 입력값 · 고른자재로 돌리며
실제로 부른 자원 줄(인력 LB · 기계 EQ · 자재 MT · 유가 MO)만 모음 — 단가 묶음 통째
(고르기 후보 전부)는 안 씀 · 값은 쓰지 않음(단가는 원가 한 벌 `price_book.json` 이 맡음).
(고르기 후보 전부)는 안 씀 · 값은 쓰지 않음(단가는 원가 한 벌 `price_book.json` 이 맡음) ·
사본 일위대가 자재 · 굳힌 사전조합 자재 코드도(183-1 · 로직을 안 돌아 `get` 에 안 걸림).
고르기가 훑은 후보 줄도 걸림 → 원가 길이 두 번 셈해 쓰는 줄만 남김(`Summary.used_materials`).
구조물 조합 사본 없으면 그 구조물은 없음 · 일위대가 없는 사본은 자원 없음.
"""
@@ -57,6 +59,10 @@ def used_keys(folder: Path) -> list[str]:
labor_doc.run_line(
snapshot, one["로직"], one.get("입력값"), one.get("켜쌓기율"), one.get("고름")
)
for mat in [*(doc.get("자재") or []), *(m for d in inner for m in d.get("자재") or [])]:
code = str(mat.get("코드") or "") if isinstance(mat, dict) else ""
if code[:2] in RESOURCE_TABLES and code not in seen:
seen.append(code)
return seen
+16 -3
View File
@@ -37,7 +37,7 @@ from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs
from B09_Estimation.B09_Estimation_Linked import linked_resource_keys
from B09_Estimation.B09_Estimation_Rates import rate_candidates, rate_table
from B09_Estimation.B09_Estimation_Router_Quotes import router as quotes_router
from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines
from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines, used_materials
router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"])
@@ -81,6 +81,19 @@ def compute_sheets(root: Path, inputs: dict[str, Any], user: dict[str, Any]) ->
return sheets
def compute_twice(root: Path, inputs: dict[str, Any], user: dict[str, Any]) -> tuple[dict, dict]:
"""183-1 두 번 셈 — 1차 셈으로 쓰는 자재만 남기고(`used_materials`) 재료비목록표 수량 =
1차 역전개 총수량(`material_qty` = 재료비수량금액집계표 수량) → 2차 셈 · 집계표.
`inputs` 를 고쳐 둠(화면 짝이 거른 입력 · 수량을 그대로 받음)."""
first = compute_sheets(root, inputs, user)
summary = compute_summary(first, hopyo_lines(first))
keep = used_materials(first, inputs, user)
inputs["materials"] = [m for m in inputs.get("materials") or [] if m["key"] in keep]
inputs["material_qty"] = {row["key"]: row["qty"] for row in summary["sum_material"]}
sheets = compute_sheets(root, inputs, user)
return sheets, compute_summary(sheets, hopyo_lines(sheets))
async def _demand(root: Path) -> list[str]:
"""단가 수요 키 — 연결된 사본 + 설계내역서 손 줄 로직(166-2)."""
keys = await asyncio.to_thread(linked_resource_keys, root)
@@ -103,7 +116,7 @@ async def payload(project_id: UUID, root: Path) -> dict[str, Any]:
inputs["hopyo"] = hopyo_prices(root)
inputs["daega"] = await asyncio.to_thread(daega_inputs, root)
user = await asyncio.to_thread(store.read_user, root)
sheets = await asyncio.to_thread(compute_sheets, root, inputs, user)
sheets, summary = await asyncio.to_thread(compute_twice, root, inputs, user)
return {
"inputs": inputs,
"user": user,
@@ -113,7 +126,7 @@ async def payload(project_id: UUID, root: Path) -> dict[str, Any]:
"rate_table": rate_table(),
"notes": notes,
# 집계표 넷 = 서버 단독(145-5) — 짝 셈 결과를 역전개 · 조작 중엔 안 바뀜
"summary": compute_summary(sheets, hopyo_lines(sheets)),
"summary": summary,
}
+19
View File
@@ -155,6 +155,25 @@ def block_lines(blocks: list[dict[str, Any]] | None) -> dict[str, Any]:
return out
def used_materials(sheets: dict[str, Any], inputs: dict[str, Any], user: dict[str, Any]) -> set:
"""재료비목록표에 남길 자재 키(183-1) — 대가 · 산근 · 중기 블록 재료 줄(수량 빈칸 · 0 도 =
집계표 역전개와 같은 줄) · B08 총괄 줄(수량 있는 입력) · 관급 · 별도 표시 줄.
그 밖(고르기 후보 줄) = 뺌."""
blocks = [*(sheets.get("sg") or []), *(sheets.get("daega_blocks") or [])]
blocks += sheets.get("machine_blocks") or []
keep = {
str((line.get("ref") or {}).get("key"))
for block in blocks
for line in block.get("lines") or []
if line.get("kind") == "material" and (line.get("ref") or {}).get("key")
}
marks = user.get("materials") or {}
for m in inputs.get("materials") or []:
if "qty" in m or m.get("supply") or (marks.get(m["key"]) or {}).get("supply"):
keep.add(m["key"])
return keep
def hopyo_lines(sheets: dict[str, Any]) -> dict[str, Any]:
"""호표 구성(1 단위당 자원 · 겹 호표) — 산근(145-1) + 대가(145-2 · 들어오면)."""
return {**block_lines(sheets.get("sg")), **block_lines(sheets.get("daega_blocks"))}
+27 -3
View File
@@ -2,7 +2,8 @@
* B09_Estimation_UI_Tabs_Base.ts
* B09 바탕 단가 탭 일곱(PLAN 144-2~4) — 노무비 · 경비목록표 · 환율및기초자료 · 자재단가대비표 ·
* 재료비목록표 · 중기사용료 · 중기목록표(실무 엑셀 꼴 · 값 = `B09_Estimation_Calc_Base`).
* 재료비목록표 = 엑셀 꼴 + 수량(B08 합계) · 총액 열(166-3)
* 재료비목록표 = 엑셀 꼴 + 수량 · 총액 열(166-3) · 줄 = 대가 · 산근 · 중기가 쓰는 자재 + B08 줄 ·
* 관급 · 별도 줄(183-1 · 서버가 거른 입력) · 수량 = 역전개 총수량 · 비고 = 쓰는 곳(`usesOf`)
*
* 고칠 칸 = 자재단가대비표 적용 단가(빈칸으로 지우면 최저 자동) · 관급 / 별도 칸 누름 = 표시 바꿈
* · 견적 단가 · 면 손입력 · 적용 「출처」 칸 누름 = 고를 출처 돌림(값 있는 출처만 · 견적이 높아도)
@@ -207,10 +208,33 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => {
};
};
/** 쓰는 곳(183-1) — 자재 키 → 「대가 n호표」 · 「산근 n호표」 · 「중기 n호표」 · 「내역 자재대」
* (블록 재료 줄 · 설계내역서 자재 항목 · 표시 잇기만 · 셈 아님) */
function usesOf(sheets: Row): Map<string, string[]> {
const out = new Map<string, string[]>();
const add = (key: unknown, label: string): void => {
if (!key) return;
const list = out.get(String(key)) ?? [];
if (!list.includes(label)) out.set(String(key), [...list, label]);
};
const scan = (blocks: Row[] | undefined, label: (b: Row, n: number) => string): void =>
(blocks || []).forEach((b, n) =>
(b.lines || []).forEach((ln: Row) => ln.kind === "material" && add(ln.ref?.key, label(b, n))),
);
scan(sheets.daega_blocks, (b) => `대가 ${b.no}호표`);
scan(sheets.sg, (b) => `산근 ${b.no}호표`);
scan(sheets.machine_blocks, (_b, n) => `중기 ${n + 1}호표`);
for (const r of sheets.bill?.rows || [])
if (r.kind === "item" && r.ref?.tab === "material") add(r.ref.key, "내역 자재대");
return out;
}
export const buildMaterial: TabBuild = ({ sheets }): TabTable => {
const uses = usesOf(sheets);
const rows: DataRow[] = (sheets.material || []).map((r: Row) => ({
...r,
supply: r.supply ?? "",
uses: (uses.get(r.key) ?? []).join(" · ") || (r.origin === "B08" ? "B08 총괄" : ""),
}));
return {
columns: [
@@ -222,14 +246,14 @@ export const buildMaterial: TabBuild = ({ sheets }): TabTable => {
money("price", "단가"),
money("amount", "총액"),
text("supply", "관급 · 별도", 80),
text("origin", "비고", 90),
text("uses", "비고(쓰는 곳)", 240),
],
rows,
total: rows.length
? { name: "계", amount: rows.reduce((sum, r) => sum + (Number(r.amount) || 0), 0) }
: null,
frozen: 2,
hint: `수량 = B08 총괄자재수량집계표 합계 열(할증 포함 · 사본 자원 줄은 빈칸) · 단가 = 자재단가대비표 적용 단가 · 총액 = ROUND(수량 × 단가, 0) · 계 = 빈칸 뺀 합 · ${LINK_HINT}`,
hint: `줄 = 연결된 일위대가 · 산근 · 중기가 쓰는 자재 + B08 총괄 줄 · 관급 · 별도 줄 · 수량 = 설계내역서 × 호표 역전개 총수량(재료비수량금액집계표와 같음 · B08 할증 값과 다를 수 있음) · 단가 = 자재단가대비표 적용 단가 · 총액 = ROUND(수량 × 단가, 0) · 계 = 빈칸 뺀 합 · 비고 = 쓰는 곳 · ${LINK_HINT}`,
link: (i) => (rows[i] ? { tab: "compare", key: String(rows[i].key) } : null),
};
};
@@ -0,0 +1,88 @@
"""183-1 재료비목록표 = 연결된 일위대가 · 산근 · 중기가 쓰는 자재만 · 수량 · 단가 한 줄 · 쓰는 곳.
- 수요: 사본 일위대가 자재 · 굳힌 사전조합 자재 코드도 든다(`Linked.used_keys`)
- 거름: 대가 · 산근 · 중기 블록 재료 줄 · B08 줄 · 관급 · 별도 줄만 남김(`Summary.used_materials`)
- 수량 = 입력 `material_qty`(1차 역전개 총수량) — `Calc_Base` 짝 거울 · 두 번 셈(`Router.compute_twice`)
- 화면 비고 = 쓰는 곳(`UI_Tabs_Base.usesOf`)
"""
import json
from pathlib import Path
from B09_Estimation import B09_Estimation_Router as router_mod
from B09_Estimation.B09_Estimation_Calc_Base import compute_base
from B09_Estimation.B09_Estimation_Linked import used_keys
from B09_Estimation.B09_Estimation_Summary import used_materials
from resources.tester.test_144_b09_base_calc import _run_ts
ROOT = Path(__file__).resolve().parents[2]
MATERIALS = [
{"key": "MT1", "name": "쓰는 자재", "prices": {"물가자료": 1000}},
{"key": "MT2", "name": "고르기 후보", "prices": {"물가자료": 500}},
{"key": "MT3", "name": "관급 표시", "prices": {"물가자료": 300}},
{"key": "MO1", "name": "경유", "prices": {"유가": 1800}},
{"key": "b08:pipe_800", "name": "파형강관", "prices": {}, "qty": 8, "supply": "관급"},
]
USER = {"materials": {"MT3": {"supply": "관급"}}}
SG = [{"key": "SG1", "no": 1, "lines": [{"kind": "material", "ref": {"key": "MT1"}, "qty": 2}]}]
MACHINE = [{"key": "EQ1", "lines": [{"kind": "material", "ref": {"key": "MO1"}, "qty": None}]}]
def test_사본_일위대가_자재_사전조합_자재_코드도_수요(tmp_path):
labor = {
"줄": {"담은로직": [], "자재": [{"코드": "MT000053"}, {"코드": "PM000001"}]},
"참조사본": {"참조|system||SC-000001": {"담은로직": [], "자재": [{"코드": "MT000008"}]}},
}
(tmp_path / "일위대가.json").write_text(json.dumps(labor, ensure_ascii=False), "utf-8")
assert used_keys(tmp_path) == ["MT000053", "MT000008"] # 개인 자재(PM) = 원가 표 밖
def test_쓰는_자재만_남김():
sheets = {"sg": SG, "daega_blocks": [], "machine_blocks": MACHINE}
inputs = {"materials": MATERIALS}
assert used_materials(sheets, inputs, USER) == {"MT1", "MO1", "MT3", "b08:pipe_800"}
def test_수량_원천_material_qty_거울():
inputs = {"labor": [], "machines": [], "materials": MATERIALS}
old = {r["key"]: r["qty"] for r in compute_base(inputs, USER)["material"]}
assert old["b08:pipe_800"] == 8 and old["MT1"] is None # 없으면 입력 줄 수량(지금 길)
got = compute_base({**inputs, "material_qty": {"MT1": 6, "b08:pipe_800": 8}}, USER)
rows = {r["key"]: r for r in got["material"]}
assert (rows["MT1"]["qty"], rows["MT1"]["amount"]) == (6, 6000)
assert rows["MT2"]["qty"] is None and rows["MT2"]["amount"] is None # 없는 키 = 빈칸
for case in (inputs, {**inputs, "material_qty": {"MT1": 6}}):
assert _run_ts(case, USER) == compute_base(case, USER)
def test_두_번_셈_후보_줄_빠지고_수량_역전개(monkeypatch):
calls = []
def fake(root, inputs, user):
calls.append([m["key"] for m in inputs["materials"]])
base = compute_base(inputs, user)
bill = {"rows": [{"kind": "item", "qty": 3, "ref": {"tab": "sg", "key": "SG1"}}]}
return {**base, "sg": SG, "daega_blocks": [], "machine_blocks": MACHINE, "bill": bill}
monkeypatch.setattr(router_mod, "compute_sheets", fake)
inputs = {"labor": [], "machines": [], "materials": list(MATERIALS)}
sheets, summary = router_mod.compute_twice(Path("."), inputs, USER)
assert calls[0] == [m["key"] for m in MATERIALS]
assert calls[1] == ["MT1", "MT3", "MO1", "b08:pipe_800"] # 후보 MT2 뺌
assert inputs["material_qty"] == {"MT1": 6.0} # 설계내역서 3 × 호표 2
rows = {r["key"]: r for r in sheets["material"]}
assert (rows["MT1"]["qty"], rows["MT1"]["amount"]) == (6.0, 6000)
assert [r["qty"] for r in summary["sum_material"]] == [rows["MT1"]["qty"]] # 집계표와 같은 값
def test_화면_비고_쓰는_곳():
src = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Base.ts").read_text(encoding="utf-8")
for label in (
"`대가 ${b.no}호표`",
"`산근 ${b.no}호표`",
"`중기 ${n + 1}호표`",
'"내역 자재대"',
):
assert label in src
assert 'text("uses", "비고(쓰는 곳)"' in src and 'text("origin", "비고"' not in src