Merge origin/dev into sub_laptop_1 — 양식 파일 충돌 풂(돌 줄 부채 걷음 + 반올림 칸 둘 다 살림)
- dev8210c2b7: 돌 줄 이름 「돌」 + 규격에 종류(_downstream_name 걷음) 그대로 취함 - sub_laptop_1cf0ccfac: _user_basis(식·반올림 근거) · m당 풀이 × 연장 그대로 둠 - 병합 뒤 전체 1535 통과 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
@@ -151,6 +151,7 @@ class MaterialRow:
|
||||
|
||||
name: str
|
||||
unit: str
|
||||
spec: str = "" # 규격 — 돌 종류·콘크리트 강도처럼 같은 이름을 가르는 값(명세 13장 Ⓒ)
|
||||
net_amount: float = 0.0 # 순수량 — 할증 전
|
||||
surcharge_pct: float | None = None # None = 미확보
|
||||
supply: str = SUPPLY_UNKNOWN
|
||||
@@ -159,6 +160,11 @@ class MaterialRow:
|
||||
basis: str = ""
|
||||
sources: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def supply_key(self) -> str:
|
||||
"""관급구분 설정(`material_supply`)을 찾는 열쇠 — 이름 + 규격."""
|
||||
return f"{self.name} {self.spec}".strip()
|
||||
|
||||
@property
|
||||
def total_amount(self) -> float:
|
||||
"""합계 = 순수량 × (1 + 할증률). 미확보면 **순수량 그대로** 두고 비고로 알린다."""
|
||||
@@ -255,11 +261,36 @@ def surcharge_lookup_name(name: str, concrete_placing_method: str | None) -> tup
|
||||
return name, ""
|
||||
|
||||
|
||||
def _add(rows: dict[tuple[str, str, str], MaterialRow], item: dict[str, Any]) -> MaterialRow:
|
||||
"""(이름·규격·단위)가 같은 줄에 더한다 — 규격이 다르면 다른 자재다(돌 종류·강도)."""
|
||||
name = str(item.get("name") or "").strip()
|
||||
spec = str(item.get("spec") or "").strip()
|
||||
unit = str(item.get("unit") or "").strip()
|
||||
row = rows.setdefault((name, spec, unit), MaterialRow(name=name, unit=unit, spec=spec))
|
||||
row.net_amount += float(item.get("amount") or 0.0)
|
||||
if item.get("surcharge_included"):
|
||||
row.surcharge_included = True
|
||||
return row
|
||||
|
||||
|
||||
def _supply_setting(supply: dict[str, Any], row: MaterialRow) -> Any:
|
||||
"""그 줄의 관급구분 설정 — 이름+규격이 먼저, 없으면 옛 열쇠.
|
||||
|
||||
⚠ 옛 열쇠 둘을 받는다 — 이름만(규격이 갈리기 전 「채움콘크리트」) · 규격만(돌 줄이
|
||||
종류 이름으로 서던 때 「야면석·호박돌」, 2026-09-13 걷음).
|
||||
안 받으면 사용자가 정한 값이 사라진다.
|
||||
"""
|
||||
for key in (row.supply_key, row.name, row.spec):
|
||||
if key and key in supply:
|
||||
return supply[key]
|
||||
return None
|
||||
|
||||
|
||||
def _collect(
|
||||
unit_quantity_table: dict[str, Any],
|
||||
) -> tuple[dict[tuple[str, str], MaterialRow], dict[str, int]]:
|
||||
) -> tuple[dict[tuple[str, str, str], MaterialRow], dict[str, int]]:
|
||||
"""`destination == "material"` 만 모은다. 나머지는 세어서 보인다."""
|
||||
rows: dict[tuple[str, str], MaterialRow] = {}
|
||||
rows: dict[tuple[str, str, str], MaterialRow] = {}
|
||||
skipped: dict[str, int] = {}
|
||||
for structure in unit_quantity_table.get("structures", []):
|
||||
label = str(structure.get("name") or structure.get("type_id") or "")
|
||||
@@ -268,12 +299,7 @@ def _collect(
|
||||
if destination != ACCEPTED_DESTINATION:
|
||||
skipped[destination] = skipped.get(destination, 0) + 1
|
||||
continue
|
||||
name = str(component.get("name") or "").strip()
|
||||
unit = str(component.get("unit") or "").strip()
|
||||
row = rows.setdefault((name, unit), MaterialRow(name=name, unit=unit))
|
||||
row.net_amount += float(component.get("amount") or 0.0)
|
||||
if component.get("surcharge_included"):
|
||||
row.surcharge_included = True
|
||||
row = _add(rows, component)
|
||||
if label and label not in row.sources:
|
||||
row.sources.append(label)
|
||||
return rows, skipped
|
||||
@@ -298,12 +324,7 @@ def build_table(
|
||||
for item in extra_materials:
|
||||
if str(item.get("destination") or ACCEPTED_DESTINATION) != ACCEPTED_DESTINATION:
|
||||
continue
|
||||
name = str(item.get("name") or "").strip()
|
||||
unit = str(item.get("unit") or "").strip()
|
||||
row = rows.setdefault((name, unit), MaterialRow(name=name, unit=unit))
|
||||
row.net_amount += float(item.get("amount") or 0.0)
|
||||
if item.get("surcharge_included"):
|
||||
row.surcharge_included = True
|
||||
row = _add(rows, item)
|
||||
source = str(item.get("source") or "")
|
||||
if source and source not in row.sources:
|
||||
row.sources.append(source)
|
||||
@@ -312,13 +333,14 @@ def build_table(
|
||||
missing_rate: list[str] = []
|
||||
missing_supply: list[str] = []
|
||||
missing_install_by: list[str] = []
|
||||
for (name, _unit), row in rows.items():
|
||||
row.supply, row.install_by = _supply_of(supply.get(name))
|
||||
for row in rows.values():
|
||||
name = row.name
|
||||
row.supply, row.install_by = _supply_of(_supply_setting(supply, row))
|
||||
if row.supply == SUPPLY_UNKNOWN:
|
||||
missing_supply.append(name)
|
||||
missing_supply.append(row.supply_key)
|
||||
# ⚠ 설치 주체는 관급 줄에만 묻는다. 사급은 애초에 대상액 밖이라 비워 두는 것이 맞다.
|
||||
if row.supply == SUPPLY_OWNER and row.install_by is None:
|
||||
missing_install_by.append(name)
|
||||
missing_install_by.append(row.supply_key)
|
||||
if row.surcharge_included:
|
||||
continue
|
||||
lookup, alias_note = surcharge_lookup_name(name, concrete_placing_method)
|
||||
@@ -328,7 +350,7 @@ def build_table(
|
||||
if rate is None:
|
||||
missing_rate.append(name)
|
||||
|
||||
ordered = sorted(rows.values(), key=lambda item: (item.name, item.unit))
|
||||
ordered = sorted(rows.values(), key=lambda item: (item.name, item.spec, item.unit))
|
||||
return {
|
||||
"columns": [
|
||||
"자재명",
|
||||
@@ -343,6 +365,9 @@ def build_table(
|
||||
"rows": [
|
||||
{
|
||||
"name": row.name,
|
||||
"spec": row.spec,
|
||||
# 관급구분을 고르는 열쇠 — 화면이 이 이름으로 설정에 적는다.
|
||||
"supply_key": row.supply_key,
|
||||
"unit": row.unit,
|
||||
"net_amount": row.net_amount,
|
||||
"surcharge_pct": row.surcharge_pct,
|
||||
|
||||
@@ -178,17 +178,6 @@ def _library_rows(
|
||||
return rows
|
||||
|
||||
|
||||
def _downstream_name(result: dict[str, Any]) -> str:
|
||||
"""뒤 단계(자재총괄·인계)가 찾는 이름 — 돌 줄만 종류 이름으로.
|
||||
|
||||
⚠ 명세 13장 Ⓒ 는 이름 고정 「돌」 + `spec` 이지만, 자재총괄·할증표·인계가 아직 **이름으로**
|
||||
찾음(1장 「문자열에서 코드로」 일감이 끝나기 전). ⏳ 부채 — 걷는 시점은 PLAN 10장.
|
||||
"""
|
||||
if result["name"] == "돌" and result.get("spec"):
|
||||
return str(result["spec"])
|
||||
return str(result["name"])
|
||||
|
||||
|
||||
def _user_basis(row: dict[str, Any]) -> str:
|
||||
"""사용자가 고친 줄의 근거 — 고친 칸만 적음(식 · 반올림), 양식 값을 괄호로."""
|
||||
parts = []
|
||||
@@ -279,7 +268,8 @@ def replace_with_templates(
|
||||
if result.get("error"):
|
||||
quantity.notes.append(f"양식 줄 「{result['name']}」이 안 섬 — {result['error']}")
|
||||
continue
|
||||
name = _downstream_name(result)
|
||||
# 돌 줄도 이름 「돌」 + 규격에 종류 그대로(명세 13장 Ⓒ) — 자재총괄이 규격까지 보고 묶음.
|
||||
name = str(result["name"])
|
||||
user = source.get("source") == "user"
|
||||
basis = (
|
||||
_user_basis(source)
|
||||
@@ -294,7 +284,7 @@ def replace_with_templates(
|
||||
str(source.get("destination") or ""),
|
||||
basis,
|
||||
source="user" if user else engine_source.get(name, ""),
|
||||
spec="" if name != result["name"] else str(result.get("spec") or ""),
|
||||
spec=str(result.get("spec") or ""),
|
||||
)
|
||||
)
|
||||
quantity.components = components
|
||||
|
||||
@@ -383,7 +383,6 @@ DESTINATION = {
|
||||
"돌쌓기": "unit_price",
|
||||
"돌붙임": "unit_price",
|
||||
"깬돌": "material",
|
||||
"야면석": "material",
|
||||
"고임돌": "material",
|
||||
"막자갈": "material",
|
||||
# ⭐ 2026-09-09 사용자 확정 3차 ⑥ — **콘크리트는 자재 축에 세운다**(사용자 명시).
|
||||
@@ -406,11 +405,8 @@ DESTINATION = {
|
||||
# 보여 주기 줄이고, 「소광리에만 있는 줄」임을 사유에 적는다.
|
||||
"입적": "reference",
|
||||
"석적": "reference",
|
||||
# 돌 종류를 고르면 그 이름으로 줄이 선다 — 자재총괄이 이름으로 찾으므로 넷 다 둔다.
|
||||
"야면석·호박돌": "material",
|
||||
"깬잡석": "material",
|
||||
"견치돌": "material",
|
||||
# 종류를 안 고른 경우의 이름 — 정본 계산표 줄 이름 그대로다(「돌 ℓ3=45cm」).
|
||||
# 돌 줄은 **이름 「돌」 + 규격(`spec`)에 종류**다(명세 13장 Ⓒ) — 정본 계산표 줄 이름 그대로
|
||||
# (「돌 ℓ3=45cm」). 자재총괄이 (이름·규격·단위)로 묶으므로 종류 이름을 따로 두지 않는다.
|
||||
"돌": "material",
|
||||
# 기초잡석(품셈 12-25) — 운반·부설·다짐 품이 붙는 **공종**이라 일위대가로 간다.
|
||||
# 자재총괄로 보내면 같은 잡석이 재료로 한 번 더 선다.
|
||||
@@ -901,8 +897,8 @@ def stone_masonry(
|
||||
# 두되(다른 자리에서 쓸 수 있다) 여기서는 안 쓴다.
|
||||
kind_label = str(picked.get("kind") or "")
|
||||
# ⭐ 2026-09-13 브레인 판정 — **계수 열 고르기와 돌종류는 다른 축.** 「실무 관행」은 계수 열만
|
||||
# 바꾸고 종류는 지워선 안 됨(야면석이 「돌」·계산식 무게로 조용히 서던 결함). 돌 이름·무게는
|
||||
# **저장 제원의 종류**를 따름 — 아는 종류일 때만(모르는 글은 종전대로 「돌」).
|
||||
# 바꾸고 종류는 지워선 안 됨(야면석이 「돌」·계산식 무게로 조용히 서던 결함). 돌 규격·무게는
|
||||
# **저장 제원의 종류**를 따름 — 아는 종류일 때만(모르는 글은 종전대로 규격 빈칸).
|
||||
chosen_kind = str(options.get(STONE_KIND_OPTION) or "").strip()
|
||||
stone_kind_name = (
|
||||
chosen_kind if chosen_kind in (load_stone_kind_table().get("kinds") or []) else kind_label
|
||||
@@ -1001,12 +997,13 @@ def stone_masonry(
|
||||
else:
|
||||
components.append(
|
||||
Component(
|
||||
stone_name,
|
||||
"돌",
|
||||
"ton",
|
||||
masonry_area * stone_ton,
|
||||
DESTINATION.get(stone_name, "material"),
|
||||
DESTINATION["돌"],
|
||||
weight_basis,
|
||||
source=weight_source,
|
||||
spec=stone_kind_name,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1555,11 +1552,12 @@ def build_table(
|
||||
totals: dict[str, dict[str, Any]] = {}
|
||||
for item in quantities:
|
||||
for component in item.components:
|
||||
key = f"{component.name}|{component.unit}"
|
||||
key = f"{component.name}|{component.spec}|{component.unit}"
|
||||
entry = totals.setdefault(
|
||||
key,
|
||||
{
|
||||
"name": component.name,
|
||||
"spec": component.spec,
|
||||
"unit": component.unit,
|
||||
"amount": 0.0,
|
||||
"destination": component.destination,
|
||||
|
||||
@@ -73,7 +73,8 @@ BED_SILL_FORMS: dict[str, dict[str, Any]] = {
|
||||
},
|
||||
"돌붙임(메)": {
|
||||
"back_len_m": 0.30,
|
||||
"stone_name": "야면석",
|
||||
"stone_name": "돌",
|
||||
"stone_kind": "야면석", # 규격 칸으로(명세 13장 Ⓒ)
|
||||
"stone_spec": "20×20×30㎝",
|
||||
"stone_ton_per_m2": 0.42,
|
||||
"wedge_stone_m3_per_m2": 0.07,
|
||||
@@ -283,7 +284,7 @@ def erosion_check_dam(
|
||||
facing = top_m * (top_t - back_m)
|
||||
base_area = masonry + facing
|
||||
|
||||
rows: list[tuple[str, str, float, str]] = [
|
||||
rows: list[tuple] = [
|
||||
("돌쌓기", "㎡", masonry, f"정면적 {front_area:.4f}㎡ × √(1+n²) · 기울기 {slope_note}"),
|
||||
("돌붙임", "㎡", facing, f"상장 {top_m:g}m × (상부두께 {top_t:g} − 뒷길이 {back_m:g})"),
|
||||
(
|
||||
@@ -305,10 +306,11 @@ def erosion_check_dam(
|
||||
else:
|
||||
rows.append(
|
||||
(
|
||||
stone_name,
|
||||
"돌",
|
||||
"ton",
|
||||
_ceil2(base_area * stone_ton),
|
||||
f"(돌쌓기+돌붙임) {base_area:.4f}㎡ {weight_tail}",
|
||||
kind, # 종류는 규격 칸으로(명세 13장 Ⓒ)
|
||||
)
|
||||
)
|
||||
wedge_per = coeff["wedge_stone_m3_per_m2"]
|
||||
@@ -358,8 +360,8 @@ def erosion_check_dam(
|
||||
)
|
||||
|
||||
components = [
|
||||
Component(name, unit, amount, DESTINATION.get(name, "quantity"), basis)
|
||||
for name, unit, amount, basis in rows
|
||||
Component(name, unit, amount, DESTINATION.get(name, "quantity"), basis, spec="".join(spec))
|
||||
for name, unit, amount, basis, *spec in rows
|
||||
]
|
||||
mpa, mpa_basis = fill_concrete_mpa(options)
|
||||
fill_per = coeff["fill_concrete_m3_per_m2"]
|
||||
@@ -548,7 +550,7 @@ def bed_sill(area_m2: float, options: dict[str, Any]) -> tuple[list[Component],
|
||||
return [], ["면적이 없어 전개하지 않음"]
|
||||
|
||||
back = table["back_len_m"]
|
||||
rows: list[tuple[str, str, float, str]] = [
|
||||
rows: list[tuple] = [
|
||||
("돌붙임", "㎡", area_m2, f"면적 {area_m2:g}㎡ (평면적 — 기울기 몫 없음)"),
|
||||
("입적", "㎥", area_m2 * back, f"면적 × 두께 {back:g}m"),
|
||||
(
|
||||
@@ -561,6 +563,7 @@ def bed_sill(area_m2: float, options: dict[str, Any]) -> tuple[list[Component],
|
||||
if form == "돌붙임(찰)"
|
||||
else " (원문 직접값 — 야면석은 유도식이 안 맞음)"
|
||||
),
|
||||
table.get("stone_kind", ""),
|
||||
),
|
||||
(
|
||||
"고임돌",
|
||||
@@ -581,8 +584,8 @@ def bed_sill(area_m2: float, options: dict[str, Any]) -> tuple[list[Component],
|
||||
rows.append(("터파기", "㎥", area_m2 * back, f"면적 × 두께 {back:g}m"))
|
||||
|
||||
components = [
|
||||
Component(name, unit, amount, DESTINATION.get(name, "quantity"), basis)
|
||||
for name, unit, amount, basis in rows
|
||||
Component(name, unit, amount, DESTINATION.get(name, "quantity"), basis, spec="".join(spec))
|
||||
for name, unit, amount, basis, *spec in rows
|
||||
]
|
||||
notes = [f"뒷길이 {back:g}m 는 정본 탭 붙박이 값입니다 — 바닥막이 제원에 뒷길이 칸이 없습니다"]
|
||||
if form == "돌붙임(찰)":
|
||||
|
||||
@@ -21,6 +21,10 @@ import {
|
||||
|
||||
export interface MaterialRow {
|
||||
name: string;
|
||||
/** 규격 — 돌 종류·콘크리트 강도(명세 13장 Ⓒ). 같은 이름도 규격이 다르면 다른 줄. */
|
||||
spec?: string;
|
||||
/** 관급구분 설정 열쇠 — 이름 + 규격(서버 `MaterialRow.supply_key`). */
|
||||
supply_key?: string;
|
||||
unit: string;
|
||||
net_amount: number;
|
||||
surcharge_pct: number | null;
|
||||
@@ -101,6 +105,8 @@ export interface UnitQuantityStructure {
|
||||
/** 값이 어디서 왔나 — `derived`(치수에서 식으로) / `observed`(실무 관측 원단위표). */
|
||||
basis_kind?: string;
|
||||
source?: string;
|
||||
/** 규격 — 돌 종류·채움 강도. 이름은 「돌」로 고정이고 종류는 여기(명세 13장 Ⓒ). */
|
||||
spec?: string;
|
||||
/** 거푸집 줄만 — 몇 회짜리인가(품셈 1-7-1). 횟수별 재료 환산은 B09 몫이다. */
|
||||
reuse_count?: number | null;
|
||||
reuse_note?: string;
|
||||
@@ -326,7 +332,8 @@ export function renderMaterialGrid(
|
||||
const body = document.createElement("tbody");
|
||||
for (const row of table.rows) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.append(textCell(row.name, "b08-grid__station"));
|
||||
const key = row.supply_key ?? row.name;
|
||||
tr.append(textCell(key, "b08-grid__station"));
|
||||
tr.append(textCell(row.unit, "b08-grid__unit"));
|
||||
tr.append(textCell(num(row.net_amount, 2)));
|
||||
// 미확보는 빈칸이 아니라 「-」 — 빈칸이면 0 % 로 오해된다.
|
||||
@@ -334,7 +341,7 @@ export function renderMaterialGrid(
|
||||
tr.append(textCell(num(row.total_amount, 2)));
|
||||
if (options) {
|
||||
// 관급/사급은 **자재마다 갈리는 발주 결정**이라 줄에서 고른다(2026-09-07 확정).
|
||||
const chosen = options.choices[row.name] ?? {
|
||||
const chosen = options.choices[key] ?? {
|
||||
supply: row.supply,
|
||||
install_by: row.install_by,
|
||||
};
|
||||
@@ -343,20 +350,20 @@ export function renderMaterialGrid(
|
||||
INSTALL_BY_OPTIONS,
|
||||
chosen.supply !== "owner_supplied", // 관급 줄에만 고를 수 있다
|
||||
(value) => {
|
||||
const current = options.choices[row.name] ?? chosen;
|
||||
options.choices[row.name] = { supply: current.supply, install_by: value || null };
|
||||
const current = options.choices[key] ?? chosen;
|
||||
options.choices[key] = { supply: current.supply, install_by: value || null };
|
||||
options.onChange();
|
||||
},
|
||||
);
|
||||
tr.append(
|
||||
choiceCell(chosen.supply, SUPPLY_OPTIONS, false, (value) => {
|
||||
const current = options.choices[row.name] ?? chosen;
|
||||
const current = options.choices[key] ?? chosen;
|
||||
const next = {
|
||||
// 사급으로 되돌리면 설치 주체는 뜻을 잃으므로 비운다.
|
||||
supply: value,
|
||||
install_by: value === "owner_supplied" ? (current.install_by ?? null) : null,
|
||||
};
|
||||
options.choices[row.name] = next;
|
||||
options.choices[key] = next;
|
||||
// ⚠ 표를 다시 그리지 않으므로 **여기서 바로 열고 닫는다** — 안 그러면 관급을 골라도
|
||||
// 설치 주체 칸이 잠긴 채 남아 사용자가 못 정한다(만들고 화면에서 걸린 자리).
|
||||
const select = installCell.querySelector("select") as HTMLSelectElement | null;
|
||||
@@ -501,7 +508,7 @@ export function renderUnitQuantityGrid(response: MaterialResponse): HTMLElement
|
||||
tr.append(textCell(first ? structure.name : "", "b08-grid__station"));
|
||||
tr.append(textCell(first ? spec : ""));
|
||||
first = false;
|
||||
tr.append(textCell(component.name));
|
||||
tr.append(textCell(`${component.name} ${component.spec ?? ""}`.trim()));
|
||||
tr.append(textCell(component.unit, "b08-grid__unit"));
|
||||
tr.append(textCell(num(component.amount, 3)));
|
||||
tr.append(textCell(DESTINATION_LABELS[component.destination] ?? component.destination));
|
||||
|
||||
@@ -70,8 +70,9 @@ def test_양식_갈음은_값을_안_움직인다() -> None:
|
||||
want["spec"],
|
||||
)
|
||||
assert got["amount"] == pytest.approx(want["amount"], rel=1e-9, abs=1e-12), got["name"]
|
||||
# 돌 줄은 뒤 단계가 찾는 종류 이름으로(1장 코드 잇기 끝나기 전) · 관측 출처는 그대로.
|
||||
stone = next(c for c in wet_after["components"] if c["name"] == "야면석·호박돌")
|
||||
# 돌 줄은 이름 「돌」 + 규격에 종류(명세 13장 Ⓒ · 2026-09-13 과도기 부채 걷음) · 관측 출처는 그대로.
|
||||
stone = next(c for c in wet_after["components"] if c["name"] == "돌")
|
||||
assert stone["spec"] == "야면석·호박돌"
|
||||
assert stone["source"] == "uljin_library"
|
||||
|
||||
|
||||
|
||||
@@ -77,7 +77,8 @@ def test_돌_중량은_값을_두고_근거를_남긴다() -> None:
|
||||
**어느 조건에서 다는지**가 달라졌으므로 조건을 명시한다.
|
||||
"""
|
||||
got, _ = 성분(stone_kind="야면석·호박돌")
|
||||
야면석 = got["야면석·호박돌"]
|
||||
야면석 = got["돌"] # 이름 「돌」 + 규격에 종류(명세 13장 Ⓒ)
|
||||
assert 야면석.spec == "야면석·호박돌"
|
||||
assert abs(야면석.amount - got["돌쌓기"].amount * 0.88) < 1e-9
|
||||
assert "울진" in 야면석.basis and "품셈·교본에는 돌중량표가 없음" in 야면석.basis
|
||||
assert 야면석.source == "uljin_library"
|
||||
|
||||
@@ -153,6 +153,43 @@ def test_단위가_다르면_다른_줄() -> None:
|
||||
assert table["row_count"] == 2
|
||||
|
||||
|
||||
def test_규격이_다르면_다른_줄이고_옛_관급_열쇠도_받음() -> None:
|
||||
"""돌 줄은 이름 「돌」 + 규격에 종류(명세 13장 Ⓒ · 2026-09-13 과도기 부채 걷음).
|
||||
|
||||
⚠ 옛 설정 열쇠 — 종류 이름(「야면석·호박돌」)·이름만(「채움콘크리트」)도 받아야
|
||||
사용자가 정한 값이 안 사라짐.
|
||||
"""
|
||||
table = build_table(
|
||||
원단위표(
|
||||
성분("돌", "ton", 3.0, spec="야면석·호박돌"),
|
||||
성분("돌", "ton", 2.0, spec="깬잡석"),
|
||||
성분("채움콘크리트", "㎥", 1.0, spec="180"),
|
||||
성분("채움콘크리트", "㎥", 4.0, spec="210"),
|
||||
),
|
||||
supply_map={
|
||||
"야면석·호박돌": SUPPLY_CONTRACTOR, # 옛 열쇠(종류 이름)
|
||||
"돌 깬잡석": SUPPLY_OWNER, # 새 열쇠(이름 + 규격)
|
||||
"채움콘크리트": SUPPLY_CONTRACTOR, # 옛 열쇠(이름만)
|
||||
},
|
||||
)
|
||||
by = {(r["name"], r["spec"]): r for r in table["rows"]}
|
||||
assert table["row_count"] == 4
|
||||
assert by[("돌", "야면석·호박돌")]["net_amount"] == pytest.approx(3.0)
|
||||
assert by[("돌", "야면석·호박돌")]["supply"] == SUPPLY_CONTRACTOR
|
||||
assert by[("돌", "깬잡석")]["supply"] == SUPPLY_OWNER
|
||||
assert by[("돌", "깬잡석")]["supply_key"] == "돌 깬잡석"
|
||||
assert by[("채움콘크리트", "210")]["supply"] == SUPPLY_CONTRACTOR
|
||||
assert table["missing_install_by_materials"] == ["돌 깬잡석"]
|
||||
|
||||
|
||||
def test_바닥막이_메붙임_돌은_규격에_야면석() -> None:
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import bed_sill
|
||||
|
||||
components, _notes = bed_sill(10.0, {"form": "돌붙임(메)"})
|
||||
stone = next(c for c in components if c.unit == "ton")
|
||||
assert (stone.name, stone.spec, stone.destination) == ("돌", "야면석", "material")
|
||||
|
||||
|
||||
# ── 할증률은 데이터에서, 없으면 드러낸다 ────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -104,12 +104,9 @@ def test_양식_값이_전개와_같다(case: dict) -> None:
|
||||
)
|
||||
# ⓘ 「실무 관행」 계수가 돌종류를 지우던 전개 결함은 2026-09-13 고침(브레인 판정) — 차이 없음.
|
||||
for row, component in zip(rows, components):
|
||||
if row["name"] == "돌":
|
||||
# 명세 13장 Ⓒ — 양식은 이름 고정 + 규격에 종류, 전개는 종류를 이름으로 씀.
|
||||
assert component["name"] == (row["spec"] or "돌")
|
||||
else:
|
||||
assert row["name"] == component["name"]
|
||||
assert row["spec"] == component["spec"], (row, component)
|
||||
# 명세 13장 Ⓒ — 돌 줄도 양식·전개 모두 이름 고정 「돌」 + 규격에 종류(2026-09-13).
|
||||
assert row["name"] == component["name"]
|
||||
assert row["spec"] == component["spec"], (row, component)
|
||||
assert template["rows"][row["seq"] - 1]["destination"] == component["destination"], row
|
||||
assert float(row["amount"]) == pytest.approx(component["amount"], rel=1e-9, abs=1e-12), (
|
||||
row["name"],
|
||||
|
||||
@@ -275,7 +275,7 @@ def test_실무_관행_계수가_돌종류를_지우지_않는다() -> None:
|
||||
"""2026-09-13 브레인 판정 — 계수 열 고르기와 돌종류는 다른 축.
|
||||
|
||||
옛 전개는 「실무 관행」을 고르면 야면석이 「돌」·계산식 무게(0.918)로 조용히 섰음.
|
||||
이제 계수만 참고자료 열(고임돌 0.15)로 가고, 돌은 야면석 이름·관측 무게(0.88)를 지킴.
|
||||
이제 계수만 참고자료 열(고임돌 0.15)로 가고, 돌은 야면석 규격·관측 무게(0.88)를 지킴.
|
||||
"""
|
||||
result = expand(
|
||||
돌쌓기찰(
|
||||
@@ -288,9 +288,10 @@ def test_실무_관행_계수가_돌종류를_지우지_않는다() -> None:
|
||||
)
|
||||
)
|
||||
돌쌓기 = 성분(result, "돌쌓기").amount
|
||||
assert 성분(result, "야면석·호박돌").amount == pytest.approx(돌쌓기 * 0.88)
|
||||
돌 = 성분(result, "돌") # 이름 「돌」 + 규격에 종류(명세 13장 Ⓒ)
|
||||
assert 돌.spec == "야면석·호박돌"
|
||||
assert 돌.amount == pytest.approx(돌쌓기 * 0.88)
|
||||
assert 성분(result, "고임돌").amount == pytest.approx(돌쌓기 * 0.15) # 관행 열
|
||||
assert not any(c.name == "돌" for c in result.components)
|
||||
|
||||
|
||||
def test_물구멍관_길이는_평균두께() -> None:
|
||||
|
||||
Reference in New Issue
Block a user