diff --git a/B08_Quantity/B08_Quantity_Engine_Earthwork_Summary.py b/B08_Quantity/B08_Quantity_Engine_Earthwork_Summary.py index 406f49d41..50710ad58 100644 --- a/B08_Quantity/B08_Quantity_Engine_Earthwork_Summary.py +++ b/B08_Quantity/B08_Quantity_Engine_Earthwork_Summary.py @@ -15,6 +15,8 @@ Q · L 출처 = 저장된 유토곡선 배분(`mass_haul.haul_plan` — B07 토 풍화암 · 보통암 · 표토제거 · 법면보호공 · 운반성토 줄은 값이 없어 비움(None) · 구조물 쪽 줄(su2 시트 구조물터파기 · 토사다짐 · 되메우기 · 잔토)은 뺌 · 거창 계수(성토면다짐 0.8 · 초류종자 성토 0.5 · 지장목 0.8)는 근거가 없어 값 빈칸 + 「비율 미정」(PLAN 143-2). +채집석 공제(160-5) = 자재 총괄 「채집석 공제」 값(돌(채집) 둘 · 고임돌 · 막자갈 · 2공구 엑셀 +토적집계표 H32 = −자재수량집계표!Y68) → 잔토처리 = ROUND(잔토 − 공제) · 공제 줄은 잔토처리 아래. 짝 — `rock_split` · `split_rock_columns` ↔ `B08_Quantity_Options_Calc.ts` `rockSplit` · `splitRockColumns` · `splitRockSummary`(거울 시험 `test_122_b08_options_mirror`). @@ -93,8 +95,10 @@ def summary_rows( totals: dict[str, float], haul: dict[str, dict[str, Any]], soft_pct: float = SOFT_ROCK_PCT_DEFAULT, + deduct: float | None = None, ) -> list[dict[str, Any]]: - """토공집계표 줄 — 시트 C5:G37 차례 · 식 그대로(셀 주소는 줄 끝 주석) · 암 = 연암 % 로 나눔.""" + """토공집계표 줄 — 시트 C5:G37 차례 · 식 그대로(셀 주소는 줄 끝 주석) · 암 = 연암 % 로 나눔 · + `deduct` = 채집석 공제 ㎥(160-5 · 없으면 빈칸).""" t = totals cut_soft, cut_hard = rock_split(t["cut_rock_volume_m3"], soft_pct) ditch_soft, ditch_hard = rock_split(t["ditch_rock_volume_m3"], soft_pct) @@ -121,7 +125,8 @@ def summary_rows( _row("종방향유용토", "", "백호우", m3, free_q, "유용토"), # 종방향무대운반거리!B25 _row("토사운반", "", "BH+DT+BH", m3, dump_q), # 평균운반거리!B42 _row("토사운반", "", "19 TON B/D", m3, dozer_q), # 평균운반거리 불도우저 ΣQ - _row("잔토처리", "", "백호우", m3, r0(t["balance_m3"])), # 토적표!T137 + _row("잔토처리", "", "백호우", m3, r0(t["balance_m3"] - (deduct or 0))), # 토적표!T137 + _row("채집석 공제", "", "돌(채집) · 고임돌 · 막자갈", m3, deduct, "잔토처리에서 뺌"), _row("층따기", "", "백호우", m3, t["benching_area_m2"]), # 토적표!W137 _row("성토면다짐", "", "", "㎡", None, unsure), # 거창 = 성토면 × 0.8 _row("표토제거", "", "", "㎡", None), diff --git a/B08_Quantity/B08_Quantity_Material_Calc.py b/B08_Quantity/B08_Quantity_Material_Calc.py index bc4724b65..2a33ded6a 100644 --- a/B08_Quantity/B08_Quantity_Material_Calc.py +++ b/B08_Quantity/B08_Quantity_Material_Calc.py @@ -15,9 +15,12 @@ 단위수량 기본값(143-4) = 연결된 사본 일위대가 자재 · 로직 재료(`summary.snapshot` · 서버가 사본 셈으로 냄 — `B08_Quantity_Router_Material.snapshot_units`) · 자재 열 = 그 사본 자재 (키 = MT 코드 · 로직 재료 = 이름|단위) + 관경 · 2공구 고정 열(`MATERIALS`)은 사용자 값이 있을 -때만 · 콘크리트소요 · -총괄로 끌어옴 = 이름 · 규격 맞춤(`matched`) · 안 맞는 사본 자재 = `unmatched`(화면 알림) · +때만(콘크리트(기계) · 모르터 1:3 = 콘크리트소요 원천이라 늘 · 160-4) · 콘크리트소요 · +총괄로 끌어옴 = 이름 · 규격 · 단위 맞춤(`matched`) · 안 맞는 사본 자재 = `unmatched` → 총괄 끝에 +그 이름 · 규격 · 단위 그대로 줄(할증 빈칸 · 끝수 없음 · 160-3) · 사본 없거나 그 자재가 없으면 빈칸 · 관매설 → 파형강관 1 은 그대로 · 손 값이 이김. +채집석 공제(160-5) = 돌(채집) 둘 · 고임돌 · 막자갈 계 합(엑셀 Y68 · 끝수 없음) → 총괄 줄 · 토공 +잔토처리에서 뺌(`B08_Quantity_Engine_Earthwork_Summary.summary_rows`). 사용자 값(`overrides`) = 프로젝트 설정 quantity › material_sheets — 고친 칸만: {"units": {줄 id: {자재: 값}}, "mix": {배합 줄: {cement|sand|gravel: 값}}, @@ -81,6 +84,11 @@ CONCRETE_ROWS: list[tuple[str, str, str, str | None, dict[str, float | None]]] = ("m13", "모르터", "1:3", "mortar_1_3", {"cement": 510, "sand": 1.10, "gravel": None}), ] MIX_FIELDS = ("cement", "sand", "gravel") +#: 늘 보이는 2공구 고정 열 — 콘크리트소요가 끌어오는 두 열(엑셀 I66 · R66 · 160-4) +ALWAYS = ("concrete_machine", "mortar_1_3") +#: 채집석 공제(160-5) — 현장 채집재 넷 → 총괄로 안 보내고 합을 토공 잔토처리에서 뺌(엑셀 Y68) +PICKED = ("stone_pick_35", "stone_pick_45", "bed_stone", "rubble") +DEDUCT_KEY = "picked_deduct" #: 총괄자재수량집계표 줄 — [키, 구분, 규격, 단위, 끌어올 곳, 할증 % 기본, 끝수] # 끌어올 곳 = "m:{자재}"(자재수량집계표 계) · "c:{cement|sand|gravel}"(콘크리트소요 계) @@ -201,7 +209,7 @@ def material_columns( for entry in MATERIALS: if entry[0] == "PIPES": out += [(f"pipe_{d}", "파형강관", f"Φ{d}", "m") for d in sizes] - elif shown is None or entry[0] in shown: + elif shown is None or entry[0] in shown or entry[0] in ALWAYS: out.append(entry) have = {m[0] for m in out} for m in extra or []: @@ -214,13 +222,23 @@ def _norm(text: str) -> str: return re.sub(r"\s+", "", str(text or "")).lower() -def matched(name: str, spec: str) -> str | None: +def _unit(text: str) -> str: + """단위 맞춤 글 — 빈칸 · 대소문 무시 · m3 = ㎥ · m2 = ㎡.""" + return _norm(text).replace("m3", "㎥").replace("m2", "㎡") + + +def matched(name: str, spec: str, unit: str) -> str | None: """사본 자재 → 2공구 고정 열 키(콘크리트소요 · 총괄 끌어옴) — 이름에 고정 이름 · 규격이 다 - 듦 · 첫 맞춤. ponytail: 글 맞춤 — 품목 이름이 실무 이름과 다르면 「맞춤 없음」(맞춤 표는 - 필요할 때).""" + 듦 + 단위 같음(kg ↔ ton 은 맞춤 아님 · 160-3) · 첫 맞춤. ponytail: 글 맞춤 — 품목 이름이 + 실무 이름과 다르면 「맞춤 없음」(총괄 끝 줄 · 맞춤 표는 146-3).""" 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: + for key, fixed, fixed_spec, fixed_unit in MATERIALS: + if ( + key != "PIPES" + and _norm(fixed) in text + and _norm(fixed_spec) in text + and _unit(unit) == _unit(fixed_unit) + ): return key return None @@ -286,10 +304,10 @@ def compute( 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: + for key, name, spec, unit in materials: if key not in sums: continue - target = key if key in fixed else matched(name, spec) + target = key if key in fixed else matched(name, spec, unit) if target is None: unmatched.append(key) else: @@ -337,20 +355,29 @@ def compute( ] else: plan.append(entry) + # 채집석 공제(160-5) · 맞춤 없는 사본 자재(160-3) — 끝수 없음(r6) · 할증 기본 빈칸 + plan.append( + (DEDUCT_KEY, "채집석 공제", "돌(채집) · 고임돌 · 막자갈", "㎥", "picked", None, "r6") + ) + by_key = {m[0]: m for m in materials} + plan += [(k, by_key[k][1], by_key[k][2], by_key[k][3], f"m:{k}", None, "r6") for k in unmatched] + picked = [pulled[k] for k in PICKED if k in pulled] total_rows = [] for key, group, spec, unit, source, pct_default, rule in plan: ref, _, divisor = source.partition("/") kind, _, name = ref.partition(":") qty: float | None = None if kind == "m": - qty = pulled.get(name) + qty = pulled.get(name, sums.get(name) if name in unmatched else None) + elif kind == "picked": + qty = round6(sum(picked)) if picked else None elif kind == "c": qty = concrete_total.get(name) elif kind == "cp": qty = couplings.get(int(name)) if qty is not None and divisor: qty = round6(qty / float(divisor)) - pct = _num(_pick(surcharge_in, key, pct_default)) + pct = None if key == DEDUCT_KEY else _num(_pick(surcharge_in, key, pct_default)) amount = finish(qty * (1 + (pct or 0) / 100), rule) if qty is not None else None total_rows.append( { diff --git a/B08_Quantity/B08_Quantity_Material_Calc.ts b/B08_Quantity/B08_Quantity_Material_Calc.ts index dc680b357..e7000a19e 100644 --- a/B08_Quantity/B08_Quantity_Material_Calc.ts +++ b/B08_Quantity/B08_Quantity_Material_Calc.ts @@ -12,7 +12,9 @@ * 사용자 값 = 고친 칸만(키가 있으면 그 값 · null = 빈칸 · 없으면 기본값) — 상수 출처는 파이썬 짝 머리 * 좌측 패널 할증(`panel`) = 총괄 모래 · 자갈 · 시멘트 줄(표 사용자 값보다 앞섬) * 단위수량 기본값(143-4) = 서버가 낸 사본 자재(`summary.snapshot`) · 자재 열 = 사본 자재 + 관경 · - * 2공구 고정 열은 사용자 값 있을 때만 · 콘크리트소요 · 총괄 끌어옴 = 이름 · 규격 맞춤(`matched`) + * 2공구 고정 열은 사용자 값 있을 때만(콘크리트(기계) · 모르터 1:3 은 늘 · 160-4) · + * 콘크리트소요 · 총괄 끌어옴 = 이름 · 규격 · 단위 맞춤(`matched`) · 맞춤 없음 = 총괄 끝 줄(160-3) + * 채집석 공제(160-5) = 채집재 넷 계 합 → 총괄 줄 · 토공 잔토처리에서 뺌 * ========================================================================== */ export type Cells = Record; @@ -77,7 +79,7 @@ export interface MaterialSheets { materials: MaterialColumn[]; rows: QuantityRow[]; total: Record; - /** 2공구 고정 열에 안 맞아 콘크리트소요 · 총괄로 못 끌어온 사본 자재 키(143-4) */ + /** 2공구 고정 열에 안 맞은 사본 자재 키(143-4) — 총괄 끝에 그대로 줄(160-3) */ unmatched: string[]; }; concrete: { rows: ConcreteRow[]; total: Record }; @@ -122,6 +124,11 @@ const CONCRETE_ROWS: Array<[string, string, string, string | null, Cells]> = [ ["m13", "모르터", "1:3", "mortar_1_3", { cement: 510, sand: 1.1, gravel: null }], ]; export const MIX_FIELDS = ["cement", "sand", "gravel"] as const; +/** 늘 보이는 2공구 고정 열 — 콘크리트소요 원천(엑셀 I66 · R66 · 160-4) · 파이썬 짝 ALWAYS */ +const ALWAYS = ["concrete_machine", "mortar_1_3"]; +/** 채집석 공제(160-5) — 채집재 넷 계 합(엑셀 Y68) · 파이썬 짝 PICKED · DEDUCT_KEY */ +const PICKED = ["stone_pick_35", "stone_pick_45", "bed_stone", "rubble"]; +export const DEDUCT_KEY = "picked_deduct"; /** 좌측 패널 할증(PLAN 124) — 총괄 모래 · 자갈 · 시멘트 줄 · 기본 = 1-3-1 현장비빔(기타) 12 · 5 · 3 */ export const PANEL_SURCHARGE: Record = { sand: 12, gravel: 5, cement: 3 }; @@ -216,7 +223,7 @@ function materialColumns( const out = MATERIALS.flatMap((entry): Entry[] => entry[0] === "PIPES" ? sizes.map((d): Entry => [`pipe_${d}`, "파형강관", `Φ${d}`, "m"]) - : shown.has(entry[0]) + : shown.has(entry[0]) || ALWAYS.includes(entry[0]) ? [entry] : [], ); @@ -230,11 +237,20 @@ const norm = (text: string): string => .replace(/\s+/g, "") .toLowerCase(); -/** 사본 자재 → 2공구 고정 열 키 — 이름에 고정 이름 · 규격이 다 듦 · 첫 맞춤(파이썬 짝 `matched`) */ -export function matched(name: string, spec: string): string | null { +/** 단위 맞춤 글 — 빈칸 · 대소문 무시 · m3 = ㎥ · m2 = ㎡(파이썬 짝 `_unit`) */ +const unitOf = (text: string): string => norm(text).replace(/m3/g, "㎥").replace(/m2/g, "㎡"); + +/** 사본 자재 → 2공구 고정 열 키 — 이름에 고정 이름 · 규격이 다 듦 + 단위 같음 · 첫 맞춤(파이썬 짝 `matched`) */ +export function matched(name: string, spec: string, unit: 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; + for (const [key, fixed, fixedSpec, fixedUnit] of MATERIALS) + if ( + key !== "PIPES" && + text.includes(norm(fixed)) && + text.includes(norm(fixedSpec)) && + unitOf(unit) === unitOf(fixedUnit) + ) + return key; return null; } @@ -291,9 +307,9 @@ export function computeMaterialSheets( 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) { + for (const [key, name, spec, unit] of materials) { if (!(key in sums)) continue; - const target = fixed.has(key) ? key : matched(name, spec); + const target = fixed.has(key) ? key : matched(name, spec, unit); if (target === null) unmatched.push(key); else add(pulled, target, sums[key]); } @@ -350,19 +366,30 @@ export function computeMaterialSheets( ] : [entry], ); + // 채집석 공제(160-5) · 맞춤 없는 사본 자재(160-3) — 끝수 없음(r6) · 할증 기본 빈칸 + plan.push([DEDUCT_KEY, "채집석 공제", "돌(채집) · 고임돌 · 막자갈", "㎥", "picked", null, "r6"]); + for (const k of unmatched) { + const [, name, spec, unit] = materials.find((m) => m[0] === k)!; + plan.push([k, name, spec, unit, `m:${k}`, null, "r6"]); + } + const picked = PICKED.filter((k) => k in pulled).map((k) => pulled[k]); const totalRows: TotalRow[] = plan.map(([key, group, spec, unit, source, pctDefault, rule]) => { const [ref, divisor = ""] = source.split("/"); const [kind, name = ""] = ref.split(":"); let qty = kind === "m" - ? (pulled[name] ?? null) - : kind === "c" - ? (concreteTotal[name] ?? null) - : kind === "cp" - ? (couplings.get(Number(name)) ?? null) - : null; + ? (pulled[name] ?? (unmatched.includes(name) ? sums[name] : null)) + : kind === "picked" + ? picked.length + ? round6(picked.reduce((a, b) => a + b, 0)) + : null + : kind === "c" + ? (concreteTotal[name] ?? null) + : kind === "cp" + ? (couplings.get(Number(name)) ?? null) + : null; if (qty !== null && divisor) qty = round6(qty / Number(divisor)); - const pct = num(pick(surchargeIn, key, pctDefault)); + const pct = key === DEDUCT_KEY ? null : num(pick(surchargeIn, key, pctDefault)); const amount = qty !== null ? finish(qty * (1 + (pct ?? 0) / 100), rule) : null; return { key, diff --git a/B08_Quantity/B08_Quantity_Options_Calc.ts b/B08_Quantity/B08_Quantity_Options_Calc.ts index 9f878382e..cb9a5715e 100644 --- a/B08_Quantity/B08_Quantity_Options_Calc.ts +++ b/B08_Quantity/B08_Quantity_Options_Calc.ts @@ -160,18 +160,20 @@ export function recountRockC( /** 엑셀 ROUND(정수) — 반은 0 에서 먼 쪽. */ const excelRound = (value: number): number => Math.sign(value) * Math.round(Math.abs(value)); -/** 토공집계표 보정량계 · 유용토 소계 · 횡방향유용토 · 잔토처리 줄 — 합계에서 다시(종방향 무대 Q 는 그대로). */ +/** 토공집계표 보정량계 · 유용토 소계 · 횡방향유용토 · 잔토처리 줄 — 합계에서 다시(종방향 무대 Q 는 그대로) · + * 잔토처리 = 잔토 − 「채집석 공제」 줄 값(160-5 · 서버가 낸 값 그대로). */ export function recountAdjustedSummary( rows: readonly R[], totals: Record, freeQ: number, ): R[] { const diverted = totals.diverted_m3 ?? 0; + const deduct = rows.find((row) => row.group === "채집석 공제")?.value ?? 0; const values: Record = { 보정량계: excelRound(totals.adjusted_total_m3 ?? 0), "유용토 소계": diverted + freeQ, 횡방향유용토: diverted, - 잔토처리: excelRound(totals.balance_m3 ?? 0), + 잔토처리: excelRound((totals.balance_m3 ?? 0) - deduct), }; return rows.map((row) => (row.group in values ? { ...row, value: values[row.group] } : row)); } diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index 3e8f81555..9fe57b191 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -9,6 +9,7 @@ 토량환산계수 = 유토곡선과 같은 `mixed_conversion_factors(quantity_settings)` · 암 = 연암 · 경암 비율 가중 C · `rock_c` = 가중 전 리핑암 · 발파암 C(조작 중 브라우저가 다시 가중 · 125-1). 암 → 연암 · 경암 = 암 × 연암 %(`rock_ratios_pct` · 기본 90 · PLAN 122) — 토적표 줄 · 합계 · 토공집계표. +잔토처리 = 잔토 − 채집석 공제(자재 총괄 저장본 · `picked_deduction` · 160-5). """ from __future__ import annotations @@ -32,6 +33,7 @@ from B08_Quantity.B08_Quantity_Engine_Earthwork_Summary import ( summary_rows, ) from B08_Quantity.B08_Quantity_Router_Hydro import _project_root +from B08_Quantity.B08_Quantity_Router_Material import picked_deduction from common_util.common_util_project_settings import ( earthwork_conversion_factors, mixed_conversion_factors, @@ -63,9 +65,10 @@ async def earthwork_table(project_id: UUID) -> dict[str, Any]: split_rock_columns(record, soft) mass_haul = ((section or {}).get("data") or {}).get("mass_haul") or {} haul = haul_lists(mass_haul.get("haul_plan")) + deduct = await picked_deduction(project_id, root) return { **table, - "summary": summary_rows(table["totals"], haul, soft), + "summary": summary_rows(table["totals"], haul, soft, deduct), "rock_ratios_pct": {"연암": soft, "경암": 100.0 - soft}, "rock_c": { kind: entry["compacted"] diff --git a/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py index f398c1134..4c7334dbd 100644 --- a/B08_Quantity/B08_Quantity_Router_Material.py +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -9,12 +9,15 @@ 좌측 패널 값(`surcharge_pct` · 저장은 옵션 길 PUT …/quantity/options) 기본값은 파일에 안 씀(읽을 때 채움). 단위수량 기본(143-4) = 연결된 사본 셈(`M02_Item_Copy.calc` = 층 셈 한 벌)의 일위대가 자재 · -로직 재료(`snapshot_units` · 서버 단독 — 브라우저는 받은 값으로 짝 계산). +로직 재료(`snapshot_units` · 서버 단독 — 브라우저는 받은 값으로 짝 계산) · 참조 줄(승격 · 사전조합) +안 재료도 셈 · 기계 주연료 · 할증(ℓ)은 뺌(원가 몫 · 160-3). """ from __future__ import annotations import asyncio +import re +from decimal import Decimal from pathlib import Path from typing import Any from uuid import UUID @@ -22,6 +25,7 @@ from uuid import UUID from fastapi import APIRouter, Body from B08_Quantity.B08_Quantity_Material_Calc import ( + DEDUCT_KEY, PANEL_KEY, _num, clean_overrides, @@ -37,19 +41,75 @@ from common_util.common_util_summary_item import GROUP_JOIN router = APIRouter(prefix="/api/projects", tags=["B08 Material"]) SETTINGS_KEY = "material_sheets" +#: 기계 주연료 · 할증 · 잡유(ℓ) — 기계경비(원가) 몫 · 자재 표에서 뺌(160-3). +# ponytail: 이름 글 맞춤 — 새 연료 이름이 나오면 여기에 더함 +FUEL = re.compile(r"연료|경유|휘발유|중유|등유|잡유|gasoline", re.IGNORECASE) + + +def _fuel(name: str, unit: str) -> bool: + return unit in ("ℓ", "L") and FUEL.search(name) is not None + + +def _qty(value: Any) -> float | None: + return _num(float(value) if isinstance(value, Decimal) else value) + + +def _ref_mats(whole: Any, read: Any, ref: dict, trail: tuple[str, ...] = ()) -> list[tuple]: + """참조 문서 1 단위 재료 `[(재료 줄, 수량)]` — 층 셈 펼침(`M02_Labor_Ref.Expand`)과 같은 길: + 승격 = 원 로직 재료 ÷ 배 · 사전조합 = 안 줄 재료 × 손 수량(빈칸 1) · 로직 줄은 알약 배로 나눔 · + 순환 · 깊이 넘음 · 없는 문서 = 빈 것.""" + from M01_MasterData import M01_MasterData_Store_Combo as combo_store + from M02_MasterTemplete import M02_Labor_Doc as labor + from M02_MasterTemplete import M02_Labor_Ref as refs + + rid = refs.ref_id(ref) + doc = read(ref) + if rid in trail or len(trail) >= refs.DEPTH or not isinstance(doc, dict): + return [] + + def run(one: dict, times: Any) -> list[tuple]: + got = labor.run_line( + whole, + str(one.get("로직") or ""), + one.get("입력값"), + one.get("켜쌓기율"), + one.get("고름"), + ) + div = _qty(times) or 1.0 + return [(m, q / div) for m in got.get("재료") or [] if (q := _qty(m.get("수량")))] + + lines = [one for one in doc.get("담은로직") or [] if isinstance(one, dict)] + if refs.kind_of(ref["키"]) == "promote": + return run(lines[0], doc.get("배")) if lines else [] + kept = doc.get("알약") + pills = kept if isinstance(kept, list) else combo_store.combo_pills({"담은로직": lines})["물량"] + by_logic = {code: pill for pill in pills for code in pill.get("로직") or []} + out: list[tuple] = [] + for one in lines: + if isinstance(one.get("참조"), dict): + sub = _ref_mats(whole, read, one["참조"], (*trail, rid)) + elif one.get("로직"): + sub = run(one, (by_logic.get(one["로직"]) or {}).get("배")) + else: + continue + hand = _qty((one.get("수량") or {}).get("손")) + out += [(m, q * (1.0 if hand is None else hand)) for m, q in sub] + return out def _folder_units(root: str | Path, folder: str, meta: dict[str, dict]) -> dict[str, float]: """사본 하나 = 집계 항목 1 단위의 자재 `{키: 수량}` — 일위대가 자재(키 = MT 코드) + 로직 재료 - (키 = 이름|단위 · 재료 수량 × 줄 수량 ÷ 배) · 줄 수량 빈칸 · 0 · 파형강관은 안 담음 · 셈 못 하면 - 빈 것. ponytail: 참조 줄(승격 · - 사전조합) 안 재료는 안 셈 — 펼친 재료가 셈 답에 오면 그때.""" + (키 = 고른 품목 MT 코드 · 없으면 이름|단위 · 재료 수량 × 줄 수량 ÷ 배) + 참조 줄 안 재료 + (160-3) · 줄 수량 빈칸 · 0 · 파형강관 · 기계 연료는 안 담음 · 셈 못 하면 빈 것.""" from M02_MasterTemplete import M02_Item_Copy as copies - from M02_MasterTemplete.M02_Item_Source import PRICE_FILE, read_json + from M02_MasterTemplete import M02_Labor_Ref as refs + from M02_MasterTemplete.M02_Item_Prices import load_prices + from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, PRICE_FILE, read_json try: got = copies.calc(root, folder) bundle = read_json(copies.folder_of(root, folder) / PRICE_FILE) or {} + labor = read_json(copies.folder_of(root, folder) / LABOR_FILE) or {} except (LookupError, ValueError): return {} rows = { @@ -67,12 +127,35 @@ def _folder_units(root: str | Path, folder: str, meta: dict[str, dict]) -> dict[ meta.setdefault(key, {"key": key, "name": name, "spec": spec, "unit": unit}) out[key] = out.get(key, 0.0) + qty + def logic_mat(mat: dict, qty: float) -> None: + name, unit = str(mat.get("이름") or ""), str(mat.get("단위") or "") + if _fuel(name, unit): + return + goods = mat.get("품목") + code = str(goods.get("키") or "") if isinstance(goods, dict) else "" + if row := rows.get(code) if code else None: # 고른 품목 = 이름 · 규격 맞춤(160-3) + put(code, str(row.get("이름") or name), str(row.get("규격") or ""), unit, qty) + else: + put(f"{name}|{unit}", name, "", unit, qty) + + frozen = (labor.get("참조사본") if isinstance(labor, dict) else None) or {} + whole = None # 사본 단가 — 참조 줄이 있을 때만 읽음 + + def read(ref: dict) -> Any: + return frozen.get(refs.ref_id(ref)) + 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 qty is None: + continue + if isinstance(line.get("참조"), dict): # 참조 줄 = 배 1 + whole = load_prices(bundle) if whole is None else whole + for mat, per in _ref_mats(whole, read, line["참조"]): + logic_mat(mat, per * qty) + continue + for mat in line.get("재료") or []: 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) + logic_mat(mat, 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: @@ -126,9 +209,12 @@ async def _sheets(project_id: UUID, root: str | Path, overrides: dict[str, Any]) sheets = compute(source, overrides, panel) notes = list(summary.get("notes") or []) if lost := sheets["quantity"]["unmatched"]: - notes.append( - f"사본 자재 {len(lost)}종 — 2공구 열 맞춤 없음(콘크리트소요 · 총괄로 안 끌어옴)" + names = {m["key"]: m for m in sheets["quantity"]["materials"]} + listed = " · ".join( + " ".join(filter(None, (names[k]["name"], names[k]["spec"], names[k]["unit"]))) + for k in lost ) + notes.append(f"2공구 열 맞춤 없음 {len(lost)}종(총괄 끝 줄 그대로) — {listed}") return { "source": source, "overrides": overrides, @@ -139,6 +225,13 @@ async def _sheets(project_id: UUID, root: str | Path, overrides: dict[str, Any]) } +async def picked_deduction(project_id: UUID, root: str | Path) -> float | None: + """채집석 공제 ㎥(160-5) — 저장본 기준 총괄 「채집석 공제」 줄 값 · 토공 잔토처리에서 뺌.""" + overrides = clean_overrides(quantity_settings(root).get(SETTINGS_KEY)) + rows = (await _sheets(project_id, root, overrides))["sheets"]["material"]["rows"] + return next((row["amount"] for row in rows if row["key"] == DEDUCT_KEY), None) + + @router.get("/{project_id}/quantity/materials") async def material_sheets(project_id: UUID) -> dict[str, Any]: root = await _project_root(project_id) diff --git a/B08_Quantity/B08_Quantity_UI_Material.ts b/B08_Quantity/B08_Quantity_UI_Material.ts index c0746c266..f6ef3f61d 100644 --- a/B08_Quantity/B08_Quantity_UI_Material.ts +++ b/B08_Quantity/B08_Quantity_UI_Material.ts @@ -8,6 +8,8 @@ * 총괄 모래 · 자갈 · 시멘트 할증 = 좌측 패널 값(PLAN 124 · 표에서 안 고침 · 패널 조작에 바로 다시 셈) * [저장] = PUT …/quantity/materials → 서버가 정본으로 다시 셈 · 캐시 비움 · 자동 저장 없음 * 값 없는 줄은 숨김(화면만 · 셈 · 열 · 단위수량은 그대로) · 표는 공용 카드 안(PLAN 143-3) + * 콘크리트(기계) · 모르터 1:3 열 = 늘 보임 · 손 단위수량 → 콘크리트소요(160-4) · 엑셀 손값과 다른 + * 배합 · 할증은 표 아래 알림 한 줄만 · 맞춤 없는 사본 자재 · 채집석 공제 = 총괄 끝 줄(160-3 · 5) * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; @@ -23,6 +25,7 @@ import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { blankUnitRows, computeMaterialSheets, + DEDUCT_KEY, MIX_FIELDS, type Cells, type MaterialOverrides, @@ -49,7 +52,8 @@ const HINTS: Record = { material: "합계 = 끝수(수량 × (1 + 할증)) — 레미콘 ROUND 2 · 철근 · 와이어메쉬 ROUND 3 · 시멘트 ROUND 0 · 그 밖 올림 · 할증 % 기본 = 건설품셈 1-3-1 · 관급 파형강관 0 — 고칠 수 있음 · 모래 · 자갈 · 시멘트는 좌측 패널", concrete: "㎥당 = 산림품셈 12-1-3 (B) 배합 · 건축 9-1-1 모르터 기본(무근은 빈칸) — 고칠 수 있음", - quantity: "수량 = 구조물 수량 × 단위수량 · 단위수량은 빈칸(사용자 입력) · 관매설은 1", + quantity: + "수량 = 구조물 수량 × 단위수량 · 단위수량은 빈칸(사용자 입력) · 관매설은 1 · 콘크리트(기계) · 모르터 1:3 단위수량 → 콘크리트소요", }; const HIDDEN = "값 없는 줄은 숨김"; @@ -157,6 +161,34 @@ const MIX_LABEL: Record<(typeof MIX_FIELDS)[number], [string, string, string]> = gravel: ["자갈", "㎥당(㎥)", "수량(㎥)"], }; +/** 2공구 엑셀 손값(조사 I) — 지금 값(품셈 근거 · 124)과 다르면 표 아래 알림 한 줄만(160-4) */ +const EXCEL_MIX: Record = { + c25: { cement: 346, sand: 0.51, gravel: 0.59 }, + c40: { cement: 323, sand: 0.48, gravel: 0.65 }, + c40_plain: { cement: 312, sand: 0.46, gravel: 0.7 }, + m12: { cement: 680, sand: 0.98 }, + m13: { cement: 510, sand: 1.1 }, +}; +const EXCEL_SURCHARGE: Cells = { sand: 6, gravel: 4, cement: 2, mixed_aggregate: 2 }; + +function excelDiffs(s: MaterialSheets): string { + const out: string[] = []; + for (const row of s.concrete.rows) + for (const f of MIX_FIELDS) { + const want = EXCEL_MIX[row.key]?.[f]; + if (want != null && row.per[f] !== want) + out.push( + `${row.name} ${row.spec} ${MIX_LABEL[f][0]} ${row.per[f] ?? "빈칸"}(엑셀 ${want})`, + ); + } + for (const row of s.material.rows) { + const want = EXCEL_SURCHARGE[row.key]; + if (want != null && (row.surcharge ?? 0) !== want) + out.push(`${row.spec} 할증 ${row.surcharge ?? 0}%(엑셀 ${want}%)`); + } + return out.length ? `2공구 엑셀 손값과 다름 — ${out.join(" · ")} · 지금 값 = 품셈 근거` : ""; +} + function concreteTable(s: MaterialSheets, o: MaterialOverrides): Table { const { rows, total } = s.concrete; const keep = shown(rows, (row) => row.qty != null || anyValue(row.amounts)); @@ -205,6 +237,7 @@ function concreteTable(s: MaterialSheets, o: MaterialOverrides): Table { function totalTable(s: MaterialSheets, o: MaterialOverrides): Table { const { rows } = s.material; + const lost = new Set(s.quantity.unmatched); const keep = shown(rows, (row) => row.qty != null || row.amount != null); return { columns: [ @@ -225,12 +258,19 @@ function totalTable(s: MaterialSheets, o: MaterialOverrides): Table { qty: row.qty, surcharge: row.surcharge, amount: row.amount, - note: row.panel ? "할증 = 좌측 패널" : "", + note: row.panel + ? "할증 = 좌측 패널" + : row.key === DEDUCT_KEY + ? "토공 잔토처리에서 뺌" + : lost.has(row.key) + ? "2공구 맞춤 없음" + : "", }; }), total: null, frozen: 2, - editable: (i, key) => key === "surcharge" && !rows[keep[i]].panel, + editable: (i, key) => + key === "surcharge" && !rows[keep[i]].panel && rows[keep[i]].key !== DEDUCT_KEY, target: (i) => [o.surcharge, rows[keep[i]].key, null], }; } @@ -312,11 +352,15 @@ export async function renderMaterialSheet( }, }); table.root.dataset.b08Material = sheet; + const excel = note(""); + excel.dataset.b08MaterialExcel = ""; const redraw = (): void => { built = BUILD[sheet](sheets, overrides); table.setRows(built.rows, built.total); + excel.textContent = sheet === "quantity" ? "" : excelDiffs(sheets); }; - host.replaceChildren(createCard({ body: [toolbar, table.root] })); + redraw(); + host.replaceChildren(createCard({ body: [toolbar, table.root, excel] })); onOptionsChange(host, () => { sheets = calc(); redraw(); diff --git a/resources/tester/test_143_3_b08_material_cards.py b/resources/tester/test_143_3_b08_material_cards.py index 9f3b3234b..6411fe75b 100644 --- a/resources/tester/test_143_3_b08_material_cards.py +++ b/resources/tester/test_143_3_b08_material_cards.py @@ -26,5 +26,5 @@ def test_자재_셋_값_없는_줄_숨김(): def test_표_넷_공용_카드_안(): - assert "createCard({ body: [toolbar, table.root] })" in MATERIAL + assert "createCard({ body: [toolbar, table.root, excel] })" in MATERIAL # + 엑셀 알림(160-4) assert "createCard({ body: parts })" in SUMMARY diff --git a/resources/tester/test_143_4_b08_snapshot_materials.py b/resources/tester/test_143_4_b08_snapshot_materials.py index a7f36212b..037018ede 100644 --- a/resources/tester/test_143_4_b08_snapshot_materials.py +++ b/resources/tester/test_143_4_b08_snapshot_materials.py @@ -53,7 +53,16 @@ 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", "고임돌|㎥", "야면석|㎥"] # 고정 열 = 손 값만 + # 고정 열 = 손 값만 · 콘크리트소요 원천 둘은 늘(160-4) + assert keys == [ + "concrete_machine", + "pipe_1000", + "mortar_1_3", + "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 @@ -62,6 +71,13 @@ def test_사본_자재가_열과_단위수량_기본(): assert rows[LEN]["units"]["pipe_1000"] == 1 # 관매설 → 파형강관 1 그대로 # 끌어옴 — 레미콘 25-18-80 · 고임돌(채집) = 이름 · 규격 맞춤 · 야면석은 맞춤 없음 assert sheets["quantity"]["unmatched"] == ["야면석|㎥"] + last = sheets["material"]["rows"][-1] # 맞춤 없음 = 총괄 끝 줄 그대로(160-3) + assert (last["key"], last["group"], last["unit"], last["qty"]) == ( + "야면석|㎥", + "야면석", + "㎥", + 15, + ) 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 diff --git a/resources/tester/test_160_3_b08_unmatched_total.py b/resources/tester/test_160_3_b08_unmatched_total.py new file mode 100644 index 000000000..2bb24e775 --- /dev/null +++ b/resources/tester/test_160_3_b08_unmatched_total.py @@ -0,0 +1,128 @@ +"""160-3 B08 자재수량 → 총괄 — 맞춤 없는 사본 자재 = 총괄 끝 줄 · 맞춤은 규격 · 단위까지 · +기계 주연료 · 할증(ℓ) 뺌 · 참조 줄(승격 · 사전조합) 안 재료 셈 · 고른 품목 = MT 코드 · 거울. +""" + +from __future__ import annotations + +import asyncio +import json +from decimal import Decimal + +from B08_Quantity import B08_Quantity_Router_Material as router +from B08_Quantity.B08_Quantity_Material_Calc import clean_overrides, compute, matched +from M02_MasterTemplete import M02_Item_Copy as copies +from M02_MasterTemplete import M02_Item_Prices as item_prices +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete.M02_Labor_Ref import ref_id +from resources.tester.test_121_b08_material_sheets import _run_ts + +REVET = "pipe:revet|돌쌓기(찰)|2.0" +SUMMARY = { + "columns": [{"id": REVET, "group": ["흙막이", "찰쌓기", "H=2.0"], "label": "m"}], + "total": {REVET: 10}, + "snapshot": { + "materials": [ + {"key": "MT1", "name": "레미콘", "spec": "25-18-80", "unit": "m3"}, + {"key": "MT2", "name": "이형철근", "spec": "D13", "unit": "ton"}, # 단위 다름 + {"key": "부직포|㎡", "name": "토목용부직포", "spec": "", "unit": "㎡"}, + ], + "units": {REVET: {"MT1": 0.31, "MT2": 0.002, "부직포|㎡": 2.871}}, + }, +} + + +def test_맞춤은_이름_규격_단위(): + assert matched("레미콘", "25-18-80", "㎥") == "remicon_25_18_80" + assert matched("레미콘", "25-18-80", "M3") == "remicon_25_18_80" # m3 = ㎥ + assert matched("이형철근", "D13", "ton") is None # kg ↔ ton 은 맞춤 아님 + assert matched("호안블럭", "", "㎥") is None # 규격 빈 고정 열도 단위는 봄 + + +def test_맞춤_없는_자재는_총괄_끝_줄_그대로(): + sheets = compute(SUMMARY, clean_overrides({"surcharge": {"부직포|㎡": 10}})) + assert sheets["quantity"]["unmatched"] == ["MT2", "부직포|㎡"] + rows = sheets["material"]["rows"] + tail = {row["key"]: row for row in rows[-2:]} + steel, cloth = tail["MT2"], tail["부직포|㎡"] + assert (steel["group"], steel["spec"], steel["unit"], steel["qty"]) == ( + "이형철근", + "D13", + "ton", + 0.02, + ) + assert steel["surcharge"] is None and steel["amount"] == 0.02 # 할증 빈칸 · 끝수 없음 + assert cloth["qty"] == 28.71 and cloth["amount"] == 31.581 # 손 할증 10 % + total = {row["key"]: row for row in rows} + assert total["remicon_25_18_80"]["qty"] == 3.1 # 맞춤 = 고정 줄로 + + +def test_거울_TS와_파이썬이_같다(): + over = clean_overrides({"surcharge": {"부직포|㎡": 10}}) + assert _run_ts(SUMMARY, over) == compute(SUMMARY, over) + + +def test_알림에_맞춤_없는_이름(tmp_path, monkeypatch): + async def summary(_project_id): + return {**SUMMARY, "notes": []} + + monkeypatch.setattr(router, "structure_summary", summary) + monkeypatch.setattr(router, "snapshot_units", lambda root, s: SUMMARY["snapshot"]) + got = asyncio.run(router._sheets("p", tmp_path, clean_overrides({}))) + assert got["notes"] == [ + "2공구 열 맞춤 없음 2종(총괄 끝 줄 그대로) — 이형철근 D13 ton · 토목용부직포 ㎡" + ] + + +SC = {"층": "project", "주인": None, "키": "SC-000001"} +SP = {"층": "project", "주인": None, "키": "SP-000001"} + + +def test_참조_줄_재료_연료_뺌_고른_품목(tmp_path, monkeypatch): + """사전조합(로직 L1 손 2 · 알약 배 10 + 승격) · 승격(L2 · 배 4) 안 재료 · 연료(ℓ) 뺌.""" + fake = { + "줄": [ + {"수량": 2, "배": 1, "참조": SC}, + {"수량": 3, "배": 1, "참조": SP}, + { + "수량": 4, + "배": 2, + "재료": [ + {"이름": "주연료 할증", "단위": "ℓ", "수량": 5}, + {"이름": "페인트", "단위": "ℓ", "수량": 1}, + {"이름": "레미콘", "단위": "㎥", "수량": 0.5, "품목": {"키": "MT9"}}, + ], + }, + ], + "자재": [], + } + price = {"묶음": {"줄": [{"키": "MT9", "이름": "레디믹스트 레미콘", "규격": "25-18-80"}]}} + frozen = { + ref_id(SC): { + "담은로직": [{"로직": "L1", "수량": {"손": 2}}, {"참조": SP}], + "알약": [{"로직": ["L1"], "배": 10}], + }, + ref_id(SP): {"담은로직": [{"로직": "L2"}], "배": 4}, + } + mats = { + "L1": [{"이름": "모래", "단위": "㎥", "수량": Decimal("5")}], + "L2": [{"이름": "돌", "단위": "㎥", "수량": 8}, {"이름": "경유", "단위": "ℓ", "수량": 3}], + } + (tmp_path / "단가.json").write_text(json.dumps(price, ensure_ascii=False), "utf-8") + (tmp_path / "일위대가.json").write_text( + json.dumps({"참조사본": frozen}, ensure_ascii=False), "utf-8" + ) + monkeypatch.setattr(copies, "calc", lambda root, folder: fake) + monkeypatch.setattr(copies, "folder_of", lambda root, folder: tmp_path) + monkeypatch.setattr(item_prices, "load_prices", lambda bundle: "whole") + monkeypatch.setattr(labor, "run_line", lambda whole, code, *rest: {"재료": mats[code]}) + meta: dict = {} + got = router._folder_units("r", "f", meta) + # 사전조합 1 단위 = 모래 5 ÷ 10 × 2 + 돌 2 · 승격 1 단위 = 돌 8 ÷ 4 · 로직 줄 = × 4 ÷ 2 + assert got == {"모래|㎥": 2.0, "돌|㎥": 4.0 + 6.0, "페인트|ℓ": 2.0, "MT9": 1.0} + assert meta["MT9"] == { + "key": "MT9", + "name": "레디믹스트 레미콘", + "spec": "25-18-80", + "unit": "㎥", + } + assert matched("레디믹스트 레미콘", "25-18-80", "㎥") == "remicon_25_18_80" diff --git a/resources/tester/test_160_4_b08_concrete_columns.py b/resources/tester/test_160_4_b08_concrete_columns.py new file mode 100644 index 000000000..863182704 --- /dev/null +++ b/resources/tester/test_160_4_b08_concrete_columns.py @@ -0,0 +1,47 @@ +"""160-4 B08 콘크리트(기계) · 모르터 1:3 열 = 사본 없어도 늘 보임 + 손 단위수량 → 콘크리트소요 +(엑셀 I66 · R66 꼴 · 레미콘은 안 거침) → 총괄 시멘트 · 모래 · 자갈 — 엑셀 2공구 검산. +""" + +from __future__ import annotations + +from pathlib import Path + +from B08_Quantity.B08_Quantity_Material_Calc import clean_overrides, compute +from resources.tester.test_121_b08_material_sheets import ( + EXCEL_MIX, + EXCEL_PANEL, + SUMMARY, + UNITS, + _run_ts, +) + +ROOT = Path(__file__).resolve().parents[2] + + +def test_두_열은_사본_없어도_늘_보임(): + bare = {"columns": SUMMARY["columns"], "total": SUMMARY["total"]} + keys = [m["key"] for m in compute(bare, clean_overrides({}))["quantity"]["materials"]] + assert keys == ["concrete_machine", "pipe_800", "pipe_1000", "pipe_1200", "mortar_1_3"] + + +def test_엑셀_검산_시멘트_모래_자갈_레미콘(): + """손 단위수량(엑셀 자재수량집계표) · 엑셀 배합 · 엑셀 할증 → 총괄 G18 244 · G4 16 · G5 19.""" + over = clean_overrides({"units": UNITS, "mix": EXCEL_MIX}) + sheets = compute(SUMMARY, over, EXCEL_PANEL) + concrete = {row["key"]: row for row in sheets["concrete"]["rows"]} + assert concrete["c40"]["qty"] == 27.73 and concrete["m13"]["qty"] == 1.162 # I66 · R66 + assert [concrete[k]["qty"] for k in ("c25", "c40_plain", "m12")] == [None] * 3 # 두 열에서만 + assert sheets["concrete"]["total"] == {"cement": 9549.41, "sand": 14.5886, "gravel": 18.0245} + total = {row["key"]: row for row in sheets["material"]["rows"]} + assert total["cement"]["amount"] == 244 + assert total["sand"]["amount"] == 16 and total["gravel"]["amount"] == 19 + assert total["remicon_25_18_80"]["amount"] == 31.62 # 레미콘 = 총괄로 곧장 + assert _run_ts(SUMMARY, over) == compute(SUMMARY, over) + + +def test_엑셀_손값과_다른_상수는_알림만(): + ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Material.ts").read_text(encoding="utf-8") + assert "2공구 엑셀 손값과 다름" in ui and "b08MaterialExcel" in ui + # 기본 배합은 품셈 근거 그대로(124) + per = {r["key"]: r["per"] for r in compute(SUMMARY, clean_overrides({}))["concrete"]["rows"]} + assert per["c40"] == {"cement": 323, "sand": 0.456, "gravel": 0.612} diff --git a/resources/tester/test_160_5_b08_picked_deduct.py b/resources/tester/test_160_5_b08_picked_deduct.py new file mode 100644 index 000000000..8d0a7a42d --- /dev/null +++ b/resources/tester/test_160_5_b08_picked_deduct.py @@ -0,0 +1,98 @@ +"""160-5 B08 채집석 공제 — 돌(채집) 둘 · 고임돌 · 막자갈 계 합(엑셀 Y68) → 총괄 줄 · 토공집계표 +잔토처리에서 뺌(엑셀 토적집계표 H32) · 잔토처리 끝수 = ROUND 0 그대로(브레인 정함) · 거울. +""" + +from __future__ import annotations + +import asyncio +import json +import subprocess +from pathlib import Path + +from B08_Quantity import B08_Quantity_Router_Material as router +from B08_Quantity.B08_Quantity_Engine_Earthwork_Summary import ( + excel_round, + haul_lists, + summary_rows, +) +from B08_Quantity.B08_Quantity_Material_Calc import clean_overrides, compute +from resources.tester.test_121_b08_material_sheets import SUMMARY, UNITS, _run_ts + +ROOT = Path(__file__).resolve().parents[2] +BALANCE = 994.4747340425532 # 엑셀 사토운반본선!K19 +TOTALS = { + "cut_soil_volume_m3": 0.0, + "cut_rock_volume_m3": 0.0, + "ditch_soil_volume_m3": 0.0, + "ditch_rock_volume_m3": 0.0, + "adjusted_total_m3": 0.0, + "fill_volume_m3": 0.0, + "diverted_m3": 0.0, + "balance_m3": BALANCE, + "benching_area_m2": 0.0, +} + + +def _deduct_row(sheets): + return next(row for row in sheets["material"]["rows"] if row["key"] == "picked_deduct") + + +def test_총괄_채집석_공제_엑셀_Y68(): + over = clean_overrides({"units": UNITS}) + sheets = compute(SUMMARY, over) + row = _deduct_row(sheets) + # 32.85 + 104.4 + 52.47 + 84.94 = 274.66 · 끝수 없음 · 할증 없음 + assert (row["group"], row["unit"], row["qty"], row["amount"]) == ( + "채집석 공제", + "㎥", + 274.66, + 274.66, + ) + assert row["surcharge"] is None + keys = {r["key"] for r in sheets["material"]["rows"] if r["qty"] is not None} + assert not {"stone_pick_35", "stone_pick_45", "bed_stone"} & keys # 채집재는 총괄로 안 감 + assert _run_ts(SUMMARY, over) == sheets + assert _deduct_row(compute(SUMMARY, clean_overrides({})))["qty"] is None # 채집재 없으면 빈칸 + + +def test_잔토처리에서_뺌_엑셀_사토(): + rows = summary_rows(TOTALS, haul_lists(None), 90, 274.66) + value = {r["group"]: r["value"] for r in rows} + assert excel_round(BALANCE - 274.66, 2) == 719.81 # 엑셀 토적집계표 F32 + assert value["잔토처리"] == 720 # ROUND 0 그대로 + assert value["채집석 공제"] == 274.66 + assert {r["group"]: r["value"] for r in summary_rows(TOTALS, haul_lists(None))}[ + "잔토처리" + ] == 994 + + +def test_거울_TS_잔토처리(): + rows = summary_rows(TOTALS, haul_lists(None), 90, 274.66) + calc = (ROOT / "B08_Quantity" / "B08_Quantity_Options_Calc.ts").read_text(encoding="utf-8") + script = calc + ( + f"\nconst rows = {json.dumps(rows, ensure_ascii=False)};" + f"\nconsole.log(JSON.stringify(recountAdjustedSummary(rows, {json.dumps(TOTALS)}, 0)));" + ) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", script], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == json.loads(json.dumps(rows)) + + +def test_서버_공제는_저장본(tmp_path, monkeypatch): + async def summary(_project_id): + return {**SUMMARY, "rows": [], "notes": []} + + async def project_root(_project_id): + return tmp_path + + monkeypatch.setattr(router, "structure_summary", summary) + monkeypatch.setattr(router, "_project_root", project_root) + assert asyncio.run(router.picked_deduction("p", tmp_path)) is None + asyncio.run(router.save_material_sheets("p", {"overrides": {"units": UNITS}})) + assert asyncio.run(router.picked_deduction("p", tmp_path)) == 274.66 + earth = (ROOT / "B08_Quantity" / "B08_Quantity_Router_Earthwork.py").read_text("utf-8") + assert 'summary_rows(table["totals"], haul, soft, deduct)' in earth diff --git a/resources/tester/test_69_b08_earthwork_tabs.py b/resources/tester/test_69_b08_earthwork_tabs.py index 63ea0b737..8b533cf56 100644 --- a/resources/tester/test_69_b08_earthwork_tabs.py +++ b/resources/tester/test_69_b08_earthwork_tabs.py @@ -202,8 +202,9 @@ def test_토공집계표_식() -> None: assert notes[group] == "비율 미정" assert value[("표토제거", "", "")] is None assert not {"구조물터파기", "토사다짐", "되메우기", "잔토"} & {r["group"] for r in rows} - assert sum(r["value"] is None and r["note"] != "비율 미정" for r in rows) == 7 - assert len(rows) == 25 # 시트 C5:C37 33 − 구조물 쪽 8 + # + 채집석 공제(160-5 · 공제 없으면 빈칸) + assert sum(r["value"] is None and r["note"] != "비율 미정" for r in rows) == 8 + assert len(rows) == 26 # 시트 C5:C37 33 − 구조물 쪽 8 + 채집석 공제 def test_토적표_머리_글자는_사용자_견본대로():