diff --git a/B08_Quantity/B08_Quantity_Material_Calc.py b/B08_Quantity/B08_Quantity_Material_Calc.py index 0cfa8dc40..bc4724b65 100644 --- a/B08_Quantity/B08_Quantity_Material_Calc.py +++ b/B08_Quantity/B08_Quantity_Material_Calc.py @@ -12,6 +12,13 @@ 그 밖 ROUNDUP 0 · 131) 구조물 수량 기준 — 일위대가(B09) 재료와 별개 · 두 번 세지 않게 화면 머리에 표시. +단위수량 기본값(143-4) = 연결된 사본 일위대가 자재 · 로직 재료(`summary.snapshot` · 서버가 +사본 셈으로 냄 — `B08_Quantity_Router_Material.snapshot_units`) · 자재 열 = 그 사본 자재 +(키 = MT 코드 · 로직 재료 = 이름|단위) + 관경 · 2공구 고정 열(`MATERIALS`)은 사용자 값이 있을 +때만 · 콘크리트소요 · +총괄로 끌어옴 = 이름 · 규격 맞춤(`matched`) · 안 맞는 사본 자재 = `unmatched`(화면 알림) · +사본 없거나 그 자재가 없으면 빈칸 · 관매설 → 파형강관 1 은 그대로 · 손 값이 이김. + 사용자 값(`overrides`) = 프로젝트 설정 quantity › material_sheets — 고친 칸만: {"units": {줄 id: {자재: 값}}, "mix": {배합 줄: {cement|sand|gravel: 값}}, "surcharge": {총괄 줄: %}} @@ -186,16 +193,38 @@ def diameters(columns: list[dict[str, Any]]) -> list[int]: return sorted(found) -def material_columns(sizes: list[int]) -> list[tuple[str, str, str, str]]: +def material_columns( + sizes: list[int], shown: set[str] | None = None, extra: list[dict[str, Any]] | None = None +) -> list[tuple[str, str, str, str]]: + """자재 열 — 고정 열은 `shown`(사용자 값 있음 · None = 전부) · 관경 · 사본 자재(`extra`) 뒤.""" out: list[tuple[str, str, str, str]] = [] for entry in MATERIALS: if entry[0] == "PIPES": out += [(f"pipe_{d}", "파형강관", f"Φ{d}", "m") for d in sizes] - else: + elif shown is None or entry[0] in shown: out.append(entry) + have = {m[0] for m in out} + for m in extra or []: + if m["key"] not in have: + out.append((m["key"], m["name"], m["spec"], m["unit"])) return out +def _norm(text: str) -> str: + return re.sub(r"\s+", "", str(text or "")).lower() + + +def matched(name: str, spec: str) -> str | None: + """사본 자재 → 2공구 고정 열 키(콘크리트소요 · 총괄 끌어옴) — 이름에 고정 이름 · 규격이 다 + 듦 · 첫 맞춤. ponytail: 글 맞춤 — 품목 이름이 실무 이름과 다르면 「맞춤 없음」(맞춤 표는 + 필요할 때).""" + text = _norm(f"{name}{spec}") + for key, fixed, fixed_spec, _ in MATERIALS: + if key != "PIPES" and _norm(fixed) in text and _norm(fixed_spec) in text: + return key + return None + + def _structure_row(column: dict[str, Any]) -> tuple[str, str, str]: """집계표 열 머리 → (구분, 규격, 단위) — 맨 아래 머리가 단위가 아니면(관보호공 모양) 규격에.""" group = [str(g) for g in column.get("group") or []] @@ -214,8 +243,11 @@ def compute( columns = summary.get("columns") or [] total = summary.get("total") or {} units_in = overrides.get("units") or {} + snapshot = summary.get("snapshot") or {} + linked_units = snapshot.get("units") or {} sizes = diameters(columns) - materials = material_columns(sizes) + shown = {k for cells in units_in.values() for k, v in cells.items() if v is not None} + materials = material_columns(sizes, shown, snapshot.get("materials")) keys = [m[0] for m in materials] # 자재수량집계표 — 집계표 열마다 한 줄(커플링은 총괄로 바로) @@ -228,7 +260,9 @@ def compute( name, spec, unit = _structure_row(column) qty = _num(total.get(cid)) length = LENGTH_ID.match(cid) - defaults = {f"pipe_{length.group(1)}": 1.0} if length else {} + defaults = {**(linked_units.get(cid) or {})} # 사본 자재(143-4) + if length: + defaults[f"pipe_{length.group(1)}"] = 1.0 given = units_in.get(cid) or {} units = {k: _num(_pick(given, k, defaults.get(k))) for k in keys} amounts: dict[str, float | None] = {} @@ -248,10 +282,23 @@ def compute( "amounts": amounts, } ) + # 끌어옴(143-4) — 고정 · 관경 열은 제 키 · 사본 자재는 이름 · 규격 맞춤 고정 키로 + fixed = {m[0] for m in MATERIALS} | {f"pipe_{d}" for d in sizes} + pulled: dict[str, float] = {} + unmatched: list[str] = [] + for key, name, spec, _ in materials: + if key not in sums: + continue + target = key if key in fixed else matched(name, spec) + if target is None: + unmatched.append(key) + else: + pulled[target] = round6(pulled.get(target, 0.0) + sums[key]) quantity = { "materials": [{"key": k, "name": n, "spec": s, "unit": u} for k, n, s, u in materials], "rows": rows, "total": sums, + "unmatched": unmatched, } # 콘크리트소요자재집계표 @@ -259,7 +306,7 @@ def compute( concrete_rows = [] concrete_total: dict[str, float] = {} for key, name, spec, source, mix in CONCRETE_ROWS: - qty = sums.get(source) if source else None + qty = pulled.get(source) if source else None given = mix_in.get(key) or {} per = {f: _num(_pick(given, f, mix[f])) for f in MIX_FIELDS} out = {} @@ -296,7 +343,7 @@ def compute( kind, _, name = ref.partition(":") qty: float | None = None if kind == "m": - qty = sums.get(name) + qty = pulled.get(name) elif kind == "c": qty = concrete_total.get(name) elif kind == "cp": diff --git a/B08_Quantity/B08_Quantity_Material_Calc.ts b/B08_Quantity/B08_Quantity_Material_Calc.ts index d5c595f5a..dc680b357 100644 --- a/B08_Quantity/B08_Quantity_Material_Calc.ts +++ b/B08_Quantity/B08_Quantity_Material_Calc.ts @@ -11,6 +11,8 @@ * 끝수 = 실무 엑셀 꼴 줄마다(레미콘 ROUND 2 · 철근 · 와이어메쉬 ROUND 3 · 시멘트 ROUND 0 · 그 밖 ROUNDUP 0) * 사용자 값 = 고친 칸만(키가 있으면 그 값 · null = 빈칸 · 없으면 기본값) — 상수 출처는 파이썬 짝 머리 * 좌측 패널 할증(`panel`) = 총괄 모래 · 자갈 · 시멘트 줄(표 사용자 값보다 앞섬) + * 단위수량 기본값(143-4) = 서버가 낸 사본 자재(`summary.snapshot`) · 자재 열 = 사본 자재 + 관경 · + * 2공구 고정 열은 사용자 값 있을 때만 · 콘크리트소요 · 총괄 끌어옴 = 이름 · 규격 맞춤(`matched`) * ========================================================================== */ export type Cells = Record; @@ -25,6 +27,8 @@ export interface MaterialOverrides { export interface SummarySource { columns: Array<{ id: string; group: string[]; label: string }>; total: Record; + /** 연결된 사본 자재(143-4 · 서버 셈) — 열 · 집계표 열마다 단위수량 기본 */ + snapshot?: { materials?: MaterialColumn[]; units?: Record }; } export interface MaterialColumn { @@ -69,7 +73,13 @@ export interface TotalRow { } export interface MaterialSheets { - quantity: { materials: MaterialColumn[]; rows: QuantityRow[]; total: Record }; + quantity: { + materials: MaterialColumn[]; + rows: QuantityRow[]; + total: Record; + /** 2공구 고정 열에 안 맞아 콘크리트소요 · 총괄로 못 끌어온 사본 자재 키(143-4) */ + unmatched: string[]; + }; concrete: { rows: ConcreteRow[]; total: Record }; material: { rows: TotalRow[] }; } @@ -197,12 +207,35 @@ export function diameters(columns: SummarySource["columns"]): number[] { return [...found].sort((a, b) => a - b); } -function materialColumns(sizes: number[]): Entry[] { - return MATERIALS.flatMap((entry): Entry[] => +/** 자재 열 — 고정 열은 `shown`(사용자 값 있음) · 관경 · 사본 자재(`extra`) 뒤 · 파이썬 짝 */ +function materialColumns( + sizes: number[], + shown: Set, + extra: MaterialColumn[] = [], +): Entry[] { + const out = MATERIALS.flatMap((entry): Entry[] => entry[0] === "PIPES" ? sizes.map((d): Entry => [`pipe_${d}`, "파형강관", `Φ${d}`, "m"]) - : [entry], + : shown.has(entry[0]) + ? [entry] + : [], ); + const have = new Set(out.map((m) => m[0])); + for (const m of extra) if (!have.has(m.key)) out.push([m.key, m.name, m.spec, m.unit]); + return out; +} + +const norm = (text: string): string => + String(text ?? "") + .replace(/\s+/g, "") + .toLowerCase(); + +/** 사본 자재 → 2공구 고정 열 키 — 이름에 고정 이름 · 규격이 다 듦 · 첫 맞춤(파이썬 짝 `matched`) */ +export function matched(name: string, spec: string): string | null { + const text = norm(`${name}${spec}`); + for (const [key, fixed, fixedSpec] of MATERIALS) + if (key !== "PIPES" && text.includes(norm(fixed)) && text.includes(norm(fixedSpec))) return key; + return null; } /** 집계표 열 머리 → [구분, 규격, 단위] — 맨 아래 머리가 단위가 아니면(관보호공 모양) 규격에 붙임 */ @@ -225,8 +258,12 @@ export function computeMaterialSheets( const columns = summary.columns ?? []; const total = summary.total ?? {}; const unitsIn = overrides.units ?? {}; + const linkedUnits = summary.snapshot?.units ?? {}; const sizes = diameters(columns); - const materials = materialColumns(sizes); + const shown = new Set(); + for (const cells of Object.values(unitsIn)) + for (const [k, v] of Object.entries(cells)) if (v !== null) shown.add(k); + const materials = materialColumns(sizes, shown, summary.snapshot?.materials); const keys = materials.map((m) => m[0]); const rows: QuantityRow[] = []; @@ -237,7 +274,8 @@ export function computeMaterialSheets( const [name, spec, unit] = structureRow(column); const qty = num(total[cid]); const length = LENGTH_ID.exec(cid); - const defaults: Cells = length ? { [`pipe_${length[1]}`]: 1 } : {}; + const defaults: Cells = { ...(linkedUnits[cid] ?? {}) }; // 사본 자재(143-4) + if (length) defaults[`pipe_${length[1]}`] = 1; const given = unitsIn[cid] ?? {}; const units: Cells = {}; const amounts: Cells = {}; @@ -249,17 +287,28 @@ export function computeMaterialSheets( } rows.push({ id: cid, name, spec, unit, qty, units, amounts }); } + // 끌어옴(143-4) — 고정 · 관경 열은 제 키 · 사본 자재는 이름 · 규격 맞춤 고정 키로 + const fixed = new Set([...MATERIALS.map((m) => m[0]), ...sizes.map((d) => `pipe_${d}`)]); + const pulled: Record = {}; + const unmatched: string[] = []; + for (const [key, name, spec] of materials) { + if (!(key in sums)) continue; + const target = fixed.has(key) ? key : matched(name, spec); + if (target === null) unmatched.push(key); + else add(pulled, target, sums[key]); + } const quantity = { materials: materials.map(([key, name, spec, unit]) => ({ key, name, spec, unit })), rows, total: sums, + unmatched, }; const mixIn = overrides.mix ?? {}; const concreteRows: ConcreteRow[] = []; const concreteTotal: Record = {}; for (const [key, name, spec, source, mix] of CONCRETE_ROWS) { - const qty = source ? (sums[source] ?? null) : null; + const qty = source ? (pulled[source] ?? null) : null; const given = mixIn[key] ?? {}; const per: Cells = {}; const amounts: Cells = {}; @@ -306,7 +355,7 @@ export function computeMaterialSheets( const [kind, name = ""] = ref.split(":"); let qty = kind === "m" - ? (sums[name] ?? null) + ? (pulled[name] ?? null) : kind === "c" ? (concreteTotal[name] ?? null) : kind === "cp" diff --git a/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py index 7df1ca4ce..f398c1134 100644 --- a/B08_Quantity/B08_Quantity_Router_Material.py +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -8,10 +8,13 @@ 계산 = `B08_Quantity_Material_Calc.py`(화면 짝 `.ts` 와 같은 식) · 총괄 모래 · 자갈 · 시멘트 할증 = 좌측 패널 값(`surcharge_pct` · 저장은 옵션 길 PUT …/quantity/options) 기본값은 파일에 안 씀(읽을 때 채움). +단위수량 기본(143-4) = 연결된 사본 셈(`M02_Item_Copy.calc` = 층 셈 한 벌)의 일위대가 자재 · +로직 재료(`snapshot_units` · 서버 단독 — 브라우저는 받은 값으로 짝 계산). """ from __future__ import annotations +import asyncio from pathlib import Path from typing import Any from uuid import UUID @@ -20,30 +23,119 @@ from fastapi import APIRouter, Body from B08_Quantity.B08_Quantity_Material_Calc import ( PANEL_KEY, + _num, clean_overrides, compute, panel_surcharge, + round6, ) from B08_Quantity.B08_Quantity_Router_Hydro import _project_root -from B08_Quantity.B08_Quantity_Router_Summary import structure_summary +from B08_Quantity.B08_Quantity_Router_Summary import _combo, structure_summary from common_util.common_util_project_settings import quantity_settings, save_section +from common_util.common_util_summary_item import GROUP_JOIN router = APIRouter(prefix="/api/projects", tags=["B08 Material"]) SETTINGS_KEY = "material_sheets" +def _folder_units(root: str | Path, folder: str, meta: dict[str, dict]) -> dict[str, float]: + """사본 하나 = 집계 항목 1 단위의 자재 `{키: 수량}` — 일위대가 자재(키 = MT 코드) + 로직 재료 + (키 = 이름|단위 · 재료 수량 × 줄 수량 ÷ 배) · 줄 수량 빈칸 · 0 · 파형강관은 안 담음 · 셈 못 하면 + 빈 것. ponytail: 참조 줄(승격 · + 사전조합) 안 재료는 안 셈 — 펼친 재료가 셈 답에 오면 그때.""" + from M02_MasterTemplete import M02_Item_Copy as copies + from M02_MasterTemplete.M02_Item_Source import PRICE_FILE, read_json + + try: + got = copies.calc(root, folder) + bundle = read_json(copies.folder_of(root, folder) / PRICE_FILE) or {} + except (LookupError, ValueError): + return {} + rows = { + row.get("키"): row + for data in bundle.values() + if isinstance(data, dict) + for row in data.get("줄") or data.get("표") or [] + if isinstance(row, dict) + } + out: dict[str, float] = {} + + def put(key: str, name: str, spec: str, unit: str, qty: float) -> None: + if not qty or name.startswith("파형강관"): # 0 · 관 몫(관매설 → 파형강관 1 · 관경 열) + return + meta.setdefault(key, {"key": key, "name": name, "spec": spec, "unit": unit}) + out[key] = out.get(key, 0.0) + qty + + for line in got.get("줄") or []: + qty, times = _num(line.get("수량")), _num(line.get("배")) or 1.0 + for mat in line.get("재료") or [] if qty is not None else []: + if (per := _num(mat.get("수량"))) is not None: + name, unit = str(mat.get("이름") or ""), str(mat.get("단위") or "") + put(f"{name}|{unit}", name, "", unit, per * qty / times) + for mat in got.get("자재") or []: + code, qty = str(mat.get("코드") or ""), _num(mat.get("수량")) + if code and qty is not None: + row = rows.get(code) or {} + put( + code, + str(row.get("이름") or code), + str(row.get("규격") or ""), + str(row.get("단위") or ""), + qty, + ) + return out + + +def snapshot_units(root: str | Path, summary: dict[str, Any]) -> dict[str, Any]: + """연결된 사본 자재 → 집계표 열마다 단위수량 기본(143-4) — 칸마다(측점 · 시설) 그 칸을 낸 + 조합의 사본 자재 × 칸 값을 모아 열 계로 나눔(시설마다 사본이 달라도 계가 맞음) · 사본 없으면 + 안 담음.""" + folders: dict[tuple[str, str | None], list[str]] = {} + for owner_id, entry in (summary.get("linked") or {}).items(): + owner = owner_id.partition(GROUP_JOIN)[0] + folders.setdefault((owner, _combo(entry.get("항목"))), []).append(entry["폴더"]) + meta: dict[str, dict] = {} + per_folder: dict[str, dict[str, float]] = {} + sums: dict[str, dict[str, float]] = {} + for cid, value, owner, combos in summary.get("sources") or []: + for folder in (f for combo in combos for f in folders.get((owner, combo), [])): + if folder not in per_folder: + per_folder[folder] = _folder_units(root, folder, meta) + cell = sums.setdefault(cid, {}) + for key, qty in per_folder[folder].items(): + cell[key] = cell.get(key, 0.0) + value * qty + total = summary.get("total") or {} + units = { + cid: {key: round6(qty / total[cid]) for key, qty in cells.items()} + for cid, cells in sums.items() + if cells and total.get(cid) + } + used = {key for cells in units.values() for key in cells} + return {"materials": [m for key, m in meta.items() if key in used], "units": units} + + async def _sheets(project_id: UUID, root: str | Path, overrides: dict[str, Any]) -> dict[str, Any]: summary = await structure_summary(project_id) - source = {"columns": summary["columns"], "total": summary["total"]} + source = { + "columns": summary["columns"], + "total": summary["total"], + "snapshot": await asyncio.to_thread(snapshot_units, root, summary), + } panel = panel_surcharge(quantity_settings(root).get(PANEL_KEY)) + sheets = compute(source, overrides, panel) + notes = list(summary.get("notes") or []) + if lost := sheets["quantity"]["unmatched"]: + notes.append( + f"사본 자재 {len(lost)}종 — 2공구 열 맞춤 없음(콘크리트소요 · 총괄로 안 끌어옴)" + ) return { "source": source, "overrides": overrides, "surcharge_pct": panel, # 구조물 집계표 알림(「집수정 모양 기록 없음 n곳」 등) — 총괄 · 콘크리트 위 빈 까닭(131) - "notes": list(summary.get("notes") or []), - "sheets": compute(source, overrides, panel), + "notes": notes, + "sheets": sheets, } diff --git a/B08_Quantity/B08_Quantity_Router_Summary.py b/B08_Quantity/B08_Quantity_Router_Summary.py index 0f2dbe848..69294c641 100644 --- a/B08_Quantity/B08_Quantity_Router_Summary.py +++ b/B08_Quantity/B08_Quantity_Router_Summary.py @@ -177,6 +177,7 @@ class _Sheet: self.rows: dict[int, dict[str, Any]] = {} self.owner = "" # 지금 셈하는 시설 — 사본 없어 안 센 칸의 시설을 모음(143-1) self.unlinked: set[str] = set() + self.sources: list[list[Any]] = [] # 칸마다 [열, 값, 시설, 조합] — 자재 재연결(143-4) def add( self, @@ -201,6 +202,7 @@ class _Sheet: row = self.rows.setdefault(round(chainage * 100), {"chainage_m": chainage, "values": {}}) if value is not None: # 열은 세움 — 놓였는데 값이 빈칸인 것이 보여야 함 row["values"][cid] = round((row["values"].get(cid) or 0.0) + value, 3) + self.sources.append([cid, value, self.owner, [c for c in combos if c]]) def result(self) -> dict[str, Any]: columns = sorted(self.columns.values(), key=lambda column: column["_sort"]) @@ -213,6 +215,7 @@ class _Sheet: "columns": [{k: v for k, v in c.items() if k != "_sort"} for c in columns], "rows": rows, "total": total, + "sources": self.sources, } diff --git a/resources/tester/test_121_b08_material_sheets.py b/resources/tester/test_121_b08_material_sheets.py index adadb712e..8cbcd0a18 100644 --- a/resources/tester/test_121_b08_material_sheets.py +++ b/resources/tester/test_121_b08_material_sheets.py @@ -104,7 +104,8 @@ def test_자재수량_계가_엑셀_66행과_같다(): assert "pipe:cp|800" not in rows revet = rows["pipe:revet|돌쌓기(찰)|2.0"] assert revet["qty"] == 20 and revet["amounts"]["stone_buy_45"] == 38.4 - assert revet["amounts"]["sod"] is None # 빈 단위수량 = 빈칸(0 아님) + assert revet["amounts"]["rubble"] == 15 and revet["amounts"]["stone_pick_45"] is None # 빈칸 + assert "sod" not in revet["amounts"] # 2공구 고정 열 = 사용자 값 · 사본 있을 때만(143-4) basin = rows["pipe:basin|800|U"] assert (basin["name"], basin["spec"], basin["unit"]) == ( "관 공", @@ -112,7 +113,7 @@ def test_자재수량_계가_엑셀_66행과_같다(): "개소", ) keys = [m["key"] for m in sheets["quantity"]["materials"]] - assert keys.index("rebar_d25") + 1 == keys.index("pipe_800") + assert keys.index("concrete_machine") + 1 == keys.index("pipe_800") assert keys.index("pipe_1200") + 1 == keys.index("mortar_1_3") diff --git a/resources/tester/test_143_4_b08_snapshot_materials.py b/resources/tester/test_143_4_b08_snapshot_materials.py new file mode 100644 index 000000000..a7f36212b --- /dev/null +++ b/resources/tester/test_143_4_b08_snapshot_materials.py @@ -0,0 +1,117 @@ +"""143-4 사본 일위대가 자재 → B08 자재수량집계표 단위수량 재연결. + +- 서버 `snapshot_units` — 칸(측점 · 시설)마다 그 칸을 낸 조합의 연결된 사본 셈(자재 · 로직 재료) × + 칸 값 ÷ 열 계 · 사본 없거나 줄 수량 빈칸이면 빈칸(안 담음) +- 짝 계산 `compute` — 열 = 사본 자재 + 관경 · 2공구 고정 열은 사용자 값 있을 때만 · 손 값이 이김 · + 관매설 → 파형강관 1 그대로 · 콘크리트소요 · 총괄 끌어옴 = 이름 · 규격 맞춤 · 안 맞으면 `unmatched` +- 거울 = TS 짝(`B08_Quantity_Material_Calc.ts`)이 같은 답 +""" + +from __future__ import annotations + +import json + +from B08_Quantity import B08_Quantity_Router_Material as router +from B08_Quantity.B08_Quantity_Material_Calc import clean_overrides, compute +from M02_MasterTemplete import M02_Item_Copy as copies +from M02_MasterTemplete import M02_Item_Snapshot as snap +from resources.tester.test_121_b08_material_sheets import _run_ts +from resources.tester.test_item_copy_complete import ( # noqa: F401 — 고정물 + FOLDER, + PIPE, + _pipe, + _save, + master_copy, + world, +) + +COMBO = "system||SS000011" +LEN = "pipe:len|1000" + +SUMMARY = { + "columns": [ + {"id": LEN, "group": ["관 공", "Φ1000m/m", "관매설"], "label": "m"}, + {"id": "pipe:revet|돌쌓기(찰)|2.0", "group": ["흙막이", "찰쌓기", "H=2.0"], "label": "m"}, + {"id": "pipe:chute|메붙임", "group": ["돌붙임", "메붙임"], "label": "㎡"}, + ], + "total": {LEN: 20, "pipe:revet|돌쌓기(찰)|2.0": 10, "pipe:chute|메붙임": 5}, + "snapshot": { + "materials": [ + {"key": "MT000001", "name": "레미콘", "spec": "25-18-80", "unit": "㎥"}, + {"key": "고임돌|㎥", "name": "고임돌", "spec": "채집", "unit": "㎥"}, + {"key": "야면석|㎥", "name": "야면석", "spec": "", "unit": "㎥"}, + ], + "units": { + "pipe:revet|돌쌓기(찰)|2.0": {"MT000001": 0.31, "고임돌|㎥": 0.2, "야면석|㎥": 1.5}, + "pipe:chute|메붙임": {"고임돌|㎥": 0.12}, + }, + }, +} + + +def test_사본_자재가_열과_단위수량_기본(): + over = clean_overrides({"units": {"pipe:chute|메붙임": {"고임돌|㎥": 0.5, "sod": 1}}}) + sheets = compute(SUMMARY, over) + keys = [m["key"] for m in sheets["quantity"]["materials"]] + assert keys == ["pipe_1000", "sod", "MT000001", "고임돌|㎥", "야면석|㎥"] # 고정 열 = 손 값만 + rows = {row["id"]: row for row in sheets["quantity"]["rows"]} + revet = rows["pipe:revet|돌쌓기(찰)|2.0"] + assert revet["units"]["MT000001"] == 0.31 and revet["amounts"]["MT000001"] == 3.1 + assert revet["units"]["pipe_1000"] is None # 사본에 없는 자재 = 빈칸 + assert rows["pipe:chute|메붙임"]["units"]["고임돌|㎥"] == 0.5 # 손 값이 이김 + assert rows[LEN]["units"]["pipe_1000"] == 1 # 관매설 → 파형강관 1 그대로 + # 끌어옴 — 레미콘 25-18-80 · 고임돌(채집) = 이름 · 규격 맞춤 · 야면석은 맞춤 없음 + assert sheets["quantity"]["unmatched"] == ["야면석|㎥"] + total = {row["key"]: row for row in sheets["material"]["rows"]} + assert total["remicon_25_18_80"]["qty"] == 3.1 + assert total["pipe_1000"]["qty"] == 20 + concrete = {row["key"]: row for row in sheets["concrete"]["rows"]} + assert concrete["c40"]["qty"] is None # 콘크리트(기계) 사본 없음 = 빈칸 + + +def test_거울_TS와_파이썬이_같다(): + over = clean_overrides({"units": {"pipe:chute|메붙임": {"고임돌|㎥": 0.5, "sod": 1}}}) + assert _run_ts(SUMMARY, over) == compute(SUMMARY, over) + bare = {k: v for k, v in SUMMARY.items() if k != "snapshot"} + assert _run_ts(bare, clean_overrides({})) == compute(bare, clean_overrides({})) + + +def test_서버_사본_셈_자재_x_칸값_나누기_열계(world): # noqa: F811 + """일위대가 자재(MT 코드 · 손 수량 2) = 단위수량 2 · 사본 없는 시설 칸은 안 셈.""" + root = world["root"] + _save(root, [_pipe(), {**_pipe(None), "chainage_m": 80.0}]) + folder = root / "templates/structures" / FOLDER + labor = json.loads((folder / "일위대가.json").read_text("utf-8")) + labor["줄"] = { + "종류": "일위대가", + "열": "x", + "단위": "m", + "담은로직": [{"로직": "GF000176"}], # 줄 수량 빈칸 — 로직 재료 안 셈 + "자재": [{"코드": "MT003810", "수량": {"손": 2}}], + } + (folder / "일위대가.json").write_text(json.dumps(labor, ensure_ascii=False), "utf-8") + summary = { + "linked": snap.linked_snapshots(root), + "sources": [[LEN, 12.0, FOLDER, [COMBO]], [LEN, 8.0, "pipe@80.0", [COMBO]]], + "total": {LEN: 20.0}, + } + got = router.snapshot_units(root, summary) + assert got["units"] == {LEN: {"MT003810": 1.2}} # 12 × 2 ÷ 20 · 80 m 관은 사본 없음 + assert [m["key"] for m in got["materials"]] == ["MT003810"] + assert got["materials"][0]["name"] != "MT003810" # 이름 = 사본 단가 묶음 품목 이름 + + +def test_로직_재료는_재료_x_줄수량_나누기_배(monkeypatch): + fake = { + "줄": [ + {"수량": 4, "배": 10, "재료": [{"이름": "돌", "단위": "㎥", "수량": 5}]}, + {"수량": None, "배": 1, "재료": [{"이름": "돌", "단위": "㎥", "수량": 9}]}, # 빈칸 + {"수량": 1, "배": 1, "재료": [{"이름": "파형강관", "단위": "m", "수량": 1}]}, # 관 몫 + ], + "자재": [], + } + monkeypatch.setattr(copies, "calc", lambda root, folder: fake) + monkeypatch.setattr(copies, "folder_of", lambda root, folder: copies.Path("없음")) + meta: dict = {} + assert router._folder_units("r", "f", meta) == {"돌|㎥": 2.0} + assert meta == {"돌|㎥": {"key": "돌|㎥", "name": "돌", "spec": "", "unit": "㎥"}}