Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1

This commit is contained in:
2026-10-09 22:54:05 +09:00
13 changed files with 542 additions and 51 deletions
@@ -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),
+39 -12
View File
@@ -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(
{
+43 -16
View File
@@ -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<string, number | null>;
@@ -77,7 +79,7 @@ export interface MaterialSheets {
materials: MaterialColumn[];
rows: QuantityRow[];
total: Record<string, number>;
/** 2공구 고정 열에 안 맞아 콘크리트소요 · 총괄로 못 끌어온 사본 자재 키(143-4) */
/** 2공구 고정 열에 안 맞은 사본 자재 키(143-4) — 총괄 끝에 그대로 줄(160-3) */
unmatched: string[];
};
concrete: { rows: ConcreteRow[]; total: Record<string, number> };
@@ -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<string, number> = { 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<string, number> = {};
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,
+4 -2
View File
@@ -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<R extends { group: string; value: number | null }>(
rows: readonly R[],
totals: Record<string, number>,
freeQ: number,
): R[] {
const diverted = totals.diverted_m3 ?? 0;
const deduct = rows.find((row) => row.group === "채집석 공제")?.value ?? 0;
const values: Record<string, number> = {
보정량계: 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));
}
@@ -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"]
+103 -10
View File
@@ -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)
+48 -4
View File
@@ -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<MaterialSheetId, string> = {
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<string, Cells> = {
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();
@@ -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
@@ -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
@@ -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"
@@ -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}
@@ -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
@@ -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_토적표_머리_글자는_사용자_견본대로():